Added source code

This commit is contained in:
Carlos Santos 2025-03-10 19:25:04 +01:00
parent 30107ed0f4
commit 017b430bf1
354 changed files with 56801 additions and 0 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
**/node_modules
**/dist
**/.angular
**/.vscode
**/e2e

318
.github/workflows/e2e-test.yml vendored Normal file
View File

@ -0,0 +1,318 @@
name: E2E Tests
on:
push:
# paths:
# - 'openvidu-components-angular/**'
# - 'openvidu-browser/**'
# pull_request:
# branches:
# - master
repository_dispatch:
types: [openvidu-components-angular]
workflow_dispatch:
env:
CHROME_VERSION: latest
jobs:
# prepare_openvidu_components_angular:
# if: false
# name: Setup
# runs-on: ubuntu-latest
# steps:
# - env:
# COMMIT_MESSAGE: ${{ github.event.client_payload.commit-message }}
# COMMIT_REF: ${{ github.event.client_payload.commit-ref }}
# run: echo Commit openvidu-components-angular
# - uses: actions/setup-node@v4
# with:
# node-version: '20'
# - name: Build openvidu-components-angular
# run: |
# git clone --depth 1 https://github.com/OpenVidu/openvidu openvidu && \
# cd openvidu/openvidu-components-angular && \
# npm install && \
# npm run lib:build && \
# npm run lib:pack && \
# mv dist/openvidu-components-angular/openvidu-components-angular-*.tgz ../../
# cd ../../ && \
# rm -rf openvidu
# - uses: actions/upload-artifact@v4
# with:
# name: artifacts
# path: ${{ github.workspace }}/**.tgz
# if-no-files-found: error
routes_guards_tests:
name: Guards and Routes
runs-on: ubuntu-latest
steps:
- name: Checkout OpenVidu Local Deployment
uses: actions/checkout@v4
with:
repository: OpenVidu/openvidu-local-deployment
ref: development
path: openvidu-local-deployment
- name: Configure Local Deployment
shell: bash
run: |
cd openvidu-local-deployment/community
./configure_lan_private_ip_linux.sh
docker compose up -d
- name: Wait for OpenVidu Local Deployment to Start
shell: bash
run: |
MAX_WAIT_SECONDS=60
SECONDS=0
until curl -s -f -o /dev/null http://localhost:7880; do
if [ $SECONDS -gt $MAX_WAIT_SECONDS ]; then
echo "OpenVidu Local Deployment did not start in $MAX_WAIT_SECONDS seconds"
exit 1
fi
echo "Waiting for openvidu-local-deployment to be ready ..."
sleep 5
SECONDS=$((SECONDS+5))
done
echo "OpenVidu Local Deployment started in $SECONDS seconds"
- uses: actions/checkout@v4
with:
ref: next
path: openvidu-meet
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Run Chrome
run: docker run --network=host -d -p 4444:4444 selenium/standalone-chrome:${{ env.CHROME_VERSION }}
- name: Prepare openvidu-meet
run: |
cd openvidu-meet
./prepare.sh
cd frontend
npm run sync:backend
cd ../backend
npm run build:prod
npm run start:prod &
- name: Run tests
env:
LAUNCH_MODE: CI
APP_URL: http://localhost:6080
run: npm run e2e:run-routes --prefix openvidu-meet/frontend
# auth_e2e_test:
# if: false
# needs: prepare_openvidu_components_angular
# name: Auth tests
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-node@v4
# with:
# node-version: '20'
# - uses: actions/download-artifact@v4
# with:
# name: artifacts
# # - name: Run Browserless Chrome
# # run: docker run -d -p 3000:3000 --network host browserless/chrome:1.53-chrome-stable
# - name: Run Chrome
# run: docker run --network=host -d -p 4444:4444 selenium/standalone-chrome:127.0
# - name: Run openvidu-local-deployment
# run: |
# git clone --depth 1 https://github.com/OpenVidu/openvidu-local-deployment
# cd openvidu-local-deployment/community
# ./configure_lan_private_ip_linux.sh
# docker compose up -d
# - name: Install dependencies and build openvidu-meet
# run: |
# npm install openvidu-components-angular-*.tgz --prefix frontend && \
# npm install --prefix backend && \
# npm run build:prod --prefix backend && \
# npm run build:prod --prefix frontend && \
# mv frontend/dist/openvidu-meet/ backend/dist/public/
# - name: Wait for openvidu-local-deployment
# run: |
# until curl -s -f -o /dev/null http://localhost:7880; do
# echo "Waiting for openvidu-local-deployment to be ready..."
# sleep 5
# done
# - name: Serve openvidu-meet
# env:
# MEET_PRIVATE_ACCESS: true
# run: npm run start --prefix backend &
# - name: Run tests
# env:
# LAUNCH_MODE: CI
# run: npm run e2e:run-auth --prefix frontend
# home_e2e_test:
# if: false
# needs: prepare_openvidu_components_angular
# name: Home tests
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-node@v4
# with:
# node-version: '20'
# - uses: actions/download-artifact@v4
# with:
# name: artifacts
# # - name: Run Browserless Chrome
# # run: docker run -d -p 3000:3000 --network host browserless/chrome:1.53-chrome-stable
# - name: Run Chrome
# run: docker run --network=host -d -p 4444:4444 selenium/standalone-chrome:127.0
# - name: Run openvidu-local-deployment
# run: |
# git clone --depth 1 https://github.com/OpenVidu/openvidu-local-deployment
# cd openvidu-local-deployment/community
# ./configure_lan_private_ip_linux.sh
# docker compose up -d
# - name: Install dependencies and build openvidu-meet
# run: |
# npm install openvidu-components-angular-*.tgz --prefix frontend && \
# npm install --prefix backend && \
# npm run build:prod --prefix backend && \
# npm run build:prod --prefix frontend && \
# mv frontend/dist/openvidu-meet/ backend/dist/public/
# - name: Wait for openvidu-local-deployment
# run: |
# until curl -s -f -o /dev/null http://localhost:7880; do
# echo "Waiting for openvidu-local-deployment to be ready..."
# sleep 5
# done
# - name: Serve openvidu-meet
# run: npm run start --prefix backend &
# - name: Run tests
# env:
# LAUNCH_MODE: CI
# MEET_PRIVATE_ACCESS: false
# run: npm run e2e:run-home --prefix frontend
# room_e2e_test:
# if: false
# needs: prepare_openvidu_components_angular
# name: Room tests
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-node@v4
# with:
# node-version: '20'
# - uses: actions/download-artifact@v4
# with:
# name: artifacts
# # - name: Run Browserless Chrome
# # run: docker run -d -p 3000:3000 --network host browserless/chrome:1.53-chrome-stable
# - name: Run Chrome
# run: docker run --network=host -d -p 4444:4444 selenium/standalone-chrome:127.0
# - name: Run openvidu-local-deployment
# run: |
# git clone --depth 1 https://github.com/OpenVidu/openvidu-local-deployment
# cd openvidu-local-deployment/community
# ./configure_lan_private_ip_linux.sh
# docker compose up -d
# - name: Install dependencies and build openvidu-meet
# run: |
# npm install openvidu-components-angular-*.tgz --prefix frontend && \
# npm install --prefix backend && \
# npm run build:prod --prefix backend && \
# npm run build:prod --prefix frontend && \
# mv frontend/dist/openvidu-meet/ backend/dist/public/
# - name: Wait for openvidu-local-deployment
# run: |
# until curl -s -f -o /dev/null http://localhost:7880; do
# echo "Waiting for openvidu-local-deployment to be ready..."
# sleep 5
# done
# - name: Serve openvidu-meet
# run: npm run start --prefix backend &
# - name: Run tests
# env:
# LAUNCH_MODE: CI
# MEET_PRIVATE_ACCESS: false
# run: npm run e2e:run-room --prefix frontend
recording_e2e_test:
name: Recordings tests
runs-on: ubuntu-latest
steps:
- name: Install ffmpeg
run: |
sudo apt-get update
sudo apt-get install -y ffmpeg
- name: Checkout OpenVidu Local Deployment
uses: actions/checkout@v4
with:
repository: OpenVidu/openvidu-local-deployment
ref: development
path: openvidu-local-deployment
- name: Configure Local Deployment
shell: bash
run: |
cd openvidu-local-deployment/community
./configure_lan_private_ip_linux.sh
docker compose up -d
- name: Wait for OpenVidu Local Deployment to Start
shell: bash
run: |
MAX_WAIT_SECONDS=60
SECONDS=0
until curl -s -f -o /dev/null http://localhost:7880; do
if [ $SECONDS -gt $MAX_WAIT_SECONDS ]; then
echo "OpenVidu Local Deployment did not start in $MAX_WAIT_SECONDS seconds"
exit 1
fi
echo "Waiting for openvidu-local-deployment to be ready ..."
sleep 5
SECONDS=$((SECONDS+5))
done
echo "OpenVidu Local Deployment started in $SECONDS seconds"
- uses: actions/checkout@v4
with:
ref: next
path: openvidu-meet
- uses: actions/setup-node@v4
with:
node-version: '22.13.1'
- name: Run Chrome
run: |
mkdir -p /tmp/downloads
docker run --network=host \
-v /tmp/downloads:/tmp/downloads \
-d selenium/standalone-chrome:${{ env.CHROME_VERSION }}
# grant permissions to the /tmp/downloads folder
docker exec -u root \
$(docker ps -q --filter ancestor=selenium/standalone-chrome:${{ env.CHROME_VERSION }}) \
chmod 777 /tmp/downloads
- name: Prepare openvidu-meet
run: |
cd openvidu-meet
./prepare.sh
cd frontend
npm run sync:backend
cd ../backend
npm run build:prod
npm run start:prod &
- name: Run tests
env:
LAUNCH_MODE: CI
APP_URL: http://localhost:6080
run: npm run e2e:run-recordings --prefix openvidu-meet/frontend

82
.github/workflows/integration-test.yaml vendored Normal file
View File

@ -0,0 +1,82 @@
name: Integration Tests
on:
push:
branches:
- next
pull_request:
branches:
- next
jobs:
embedded_auth_tests:
name: Embedded Auth API
runs-on: ubuntu-latest
steps:
- name: Checkout OpenVidu Local Deployment
uses: actions/checkout@v4
with:
repository: OpenVidu/openvidu-local-deployment
ref: development
path: openvidu-local-deployment
- name: Configure Local Deployment
shell: bash
run: |
cd openvidu-local-deployment/community
./configure_lan_private_ip_linux.sh
docker compose up -d
- name: Wait for OpenVidu Local Deployment to Start
shell: bash
run: |
MAX_WAIT_SECONDS=60
SECONDS=0
until curl -s -f -o /dev/null http://localhost:7880; do
if [ $SECONDS -gt $MAX_WAIT_SECONDS ]; then
echo "OpenVidu Local Deployment did not start in $MAX_WAIT_SECONDS seconds"
exit 1
fi
echo "Waiting for openvidu-local-deployment to be ready ..."
sleep 5
SECONDS=$((SECONDS+5))
done
echo "OpenVidu Local Deployment started in $SECONDS seconds"
- uses: actions/checkout@v4
with:
ref: next
path: openvidu-meet
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Run openvidu-meet backend
run: |
cd openvidu-meet
./prepare.sh
cd backend
npm install
npm run test:embedded-auth-api
openvidu_webhook_tests:
name: OpenVidu Webhooks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: next
path: openvidu-meet
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Run openvidu-meet backend
run: |
cd openvidu-meet
./prepare.sh
cd backend
npm install
npm run test:integration-webhooks

57
.gitignore vendored Normal file
View File

@ -0,0 +1,57 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
*.angular
**/dist
/tmp
/out-tsc
*.tgz
# dependencies
**/*/node_modules
**/*/target
# profiling files
chrome-profiler-events*.json
speed-measure-plugin*.json
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.history/*
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
.editorconfig
*.browserslistrc
.git/*
backend/public/
frontend/projects/shared-meet-components/src/lib/typings/
backend/src/typings/
**/*/coverage
frontend/webcomponent/**/openvidu-meet.**.js

107
README.md Normal file
View File

@ -0,0 +1,107 @@
# OpenVidu Meet
OpenVidu Meet is a versatile video conferencing application powered by **OpenVidu**, designed to support a wide range of use cases, from personal video calls to professional webinars. Built with **Angular**, OpenVidu Meet offers two distinct modes of operation to cater to both non-technical users and developers seeking deep integration with their applications.
# Table of Contents
1. [Architecture Overview](#architecture-overview)
4. [Development](#development)
- [1. Clone the Repository](#1-clone-the-openvidu-meet-repository)
- [2. Prepare the Project](#2-prepare-the-project)
- [3. Start the Backend](#3-start-the-backend)
- [4. Start the Frontend](#4-start-the-frontend)
5. [Build (with Docker)](#build-with-docker)
- [Build the Backend Image](#build-the-backend-image)
- [Run the Backend Container](#run-the-backend-container)
## Architecture Overview
The OpenVidu Meet application is composed of two main parts (frontend and backend) that interact with each other to provide the video conferencing service. The following diagram illustrates the architecture of the application:
[![OpenVidu Meet CE Architecture Overview](docs/openvidu-meet-ce-architecture.png)](/docs/openvidu-meet-ce-architecture.png)
- **Frontend**: The frontend is a web application built with Angular that provides the user interface for the video conferencing service. This project contains the **shared-meet-components** subproject, which is a library of shared components that share administration and preference components.
Also, the frontend project installs external dependencies on the following libraries:
- [**openvidu-components-angular**](https://github.com/OpenVidu/openvidu/tree/master/openvidu-components-angular): A library of Angular components that provide the core functionality of the video conferencing service.
- [**typing**](./types/): Common types used by the frontend and backend.
- **Backend**: The backend is a Node.js application.
- [**typings**](./types/): Common types used by the frontend and backend.
## Development
For development purposes, you can run the application locally by following the instructions below.
**1. Clone the OpenVidu Meet repository:**
```bash
git clone https://github.com/OpenVidu/openvidu-meet.git
```
**2. Prepare the project**
For building types and install dependencies, run the following command:
```bash
cd openvidu-meet
./prepare.sh
```
> [!NOTE]
> **The script prepare and build all necessary dependencies ( typings) for running the frontend and backend.**
>
>
> - For building the **typings**, you can run the following command:
>
> ```bash
> cd openvidu-meet/types
> npm run sync-ce
> ```
**3. Start the Backend**
```bash
cd backend && \
npm run start:dev
```
**4. Start the Frontend**
Opening a new tab, under root directory:
```bash
cd frontend && \
npm run build:dev
```
This command will build the frontend application and move the files to the backend project. It will also listen for changes in the frontend application and rebuild the application when changes are detected.
After running these commands, you can access the frontend application at [http://localhost:6080](http://localhost:6080).
## Build (with docker)
### Build the backend image
```bash
cd docker
./create_image.sh openvidu-meet-ce
```
### Run the backend container
Once the image is created, you can run the container with the following command:
```bash
docker run \
-e LIVEKIT_URL=<your-livekit-url> \
-e LIVEKIT_API_KEY=<your-livekit-api-key> \
-e LIVEKIT_API_SECRET=<your-livekit-api-secret> \
-p 6080:6080 \
openvidu-meet-ce
```

3
backend/.env.development Normal file
View File

@ -0,0 +1,3 @@
USE_HTTPS=false
MEET_LOG_LEVEL=debug
SERVER_CORS_ORIGIN=*

45
backend/.eslintrc.json Normal file
View File

@ -0,0 +1,45 @@
{
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/no-inferrable-types": "warn",
"@typescript-eslint/no-unused-vars": "warn",
"lines-between-class-members": [
"warn",
{
"enforce": [
{
"blankLine": "always",
"prev": "method",
"next": "method"
}
]
}
],
"padding-line-between-statements": [
"warn",
{
"blankLine": "always",
"prev": "*",
"next": ["if", "for", "while", "switch"]
},
{
"blankLine": "always",
"prev": ["if", "for", "while", "switch"],
"next": "*"
},
{ "blankLine": "always", "prev": "*", "next": "block-like" },
{ "blankLine": "always", "prev": "block-like", "next": "*" }
]
}
}

10
backend/.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"singleQuote": true,
"printWidth": 120,
"trailingComma": "none",
"semi": true,
"bracketSpacing": true,
"useTabs": true,
"jsxSingleQuote": true,
"tabWidth": 4
}

3
backend/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["orta.vscode-jest"]
}

5
backend/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"jest.jestCommandLine": "node --experimental-vm-modules ./node_modules/.bin/jest --config jest.config.mjs",
"jest.rootPath": "./",
"jest.runMode": "on-demand"
}

24
backend/README.md Normal file
View File

@ -0,0 +1,24 @@
# OpenVidu Meet Backend
This is the backend of OpenVidu Meet. It is a Node.js application that uses [Express](https://expressjs.com/) as web server.
## How to run
For running the backend you need to have installed [Node.js](https://nodejs.org/). Then, you can run the following commands:
```bash
npm install
npm run start:dev
```
This will start the backend in development mode. The server will listen on port 6080.
You can change the port and other default values in the file `src/config.ts`.
## How to build
For building the backend you can run the following command:
```bash
npm install
npm run build:prod
```

8
backend/index.ts Normal file
View File

@ -0,0 +1,8 @@
export * from './src/routes/index.js';
export * from './src/controllers/index.js';
export * from './src/services/index.js';
export * from './src/models/index.js';
export * from './src/helpers/index.js';
export * from './src/environment.js';
export * from './src/config/index.js';
export * from './src/middlewares/index.js';

29
backend/jest.config.mjs Normal file
View File

@ -0,0 +1,29 @@
import { createDefaultEsmPreset } from 'ts-jest';
/** @type {import('ts-jest').JestConfigWithTsJest} */
const jestConfig = {
displayName: 'backend',
...createDefaultEsmPreset({
tsconfig: 'tsconfig.json'
}),
resolver: 'ts-jest-resolver',
testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'],
moduleFileExtensions: ['js', 'ts', 'json', 'node'],
testEnvironment: 'node',
moduleNameMapper: {
'^@typings-ce$': '<rootDir>/src/typings/ce/index.ts'
},
globals: {
'ts-jest': {
tsconfig: 'tsconfig.json'
}
}
// transform: {
// '^.+\\.tsx?$': ['ts-jest', {
// // Opcionalmente, especifica el archivo tsconfig si es necesario
// tsconfig: 'tsconfig.json',
// }],
// },
};
export default jestConfig;

9
backend/nodemon.json Normal file
View File

@ -0,0 +1,9 @@
{
"env": {
"NODE_ENV": "development"
},
"watch": ["openapi/openvidu-meet-api.yaml", "src"],
"ext": "js,json,ts",
"ignore": ["node_modules", "dist"],
"exec": "node --experimental-specifier-resolution=node --loader ts-node/esm ./src/server.ts"
}

View File

@ -0,0 +1,600 @@
openapi: 3.0.1
info:
version: v1
title: OpenVidu Meet API
description: >
The OpenVidu Embedded API allows seamless integration of OpenVidu Meet rooms into your application.
This API provides endpoints to manage rooms, generate secure access URLs, and configure room preferences.
termsOfService: https://openvidu.io/conditions/terms-of-service/
contact:
name: OpenVidu
email: commercial@openvidu.io
url: https://openvidu.io/support/
servers:
- url: http://localhost:6080/meet/api/v1
description: Development server
tags:
- name: Room
description: Operations related to managing OpenVidu Meet rooms
- name: Participant
description: Operations related to managing participants in OpenVidu Meet rooms
paths:
/rooms:
post:
operationId: createRoom
summary: Create a new OpenVidu Meet room
description: >
Creates a new OpenVidu Meet room with the specified expiration date.
The room will be available for participants to join using the generated URLs.
tags:
- Room
security:
- apiKeyInHeader: []
requestBody:
description: Room configuration options
content:
application/json:
schema:
$ref: '#/components/schemas/OpenViduMeetRoomOptions'
examples:
default:
value:
expirationDate: 1620000000000
roomNamePrefix: 'OpenVidu'
maxParticipants: 10
preferences:
chatPreferences:
enabled: true
recordingPreferences:
enabled: true
virtualBackgroundPreferences:
enabled: true
withNestedAttributes:
summary: Room creation with nested preferences
value:
expirationDate: 1620000000000
roomNamePrefix: 'OpenVidu'
maxParticipants: 15
preferences:
chatPreferences:
enabled: true
recordingPreferences:
enabled: false
virtualBackgroundPreferences:
enabled: true
responses:
'200':
description: Successfully generated the OpenVidu Meet room
content:
application/json:
schema:
$ref: '#/components/schemas/OpenViduMeetRoom'
'401':
description: Unauthorized — The API key is missing or invalid
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
message: 'Unauthorized'
'422':
description: Unprocessable Entity — The request body is invalid
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: 'Unprocessable Entity'
message:
type: string
example: 'Invalid request body'
details:
type: array
items:
type: object
properties:
field:
type: string
example: 'expirationDate'
message:
type: string
example: 'Expected number, received string'
example:
error: 'Unprocessable Entity'
message: 'Invalid request body'
details:
- field: 'expirationDate'
message: 'Expected number, received string'
- field: 'roomNamePrefix'
message: 'Expected string, received number'
'415':
description: 'Unsupported Media Type'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
message: 'Unsupported Media Type'
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
message: 'Internal server error'
get:
operationId: getRooms
summary: Get a list of OpenVidu Meet rooms
description: >
Retrieves a list of OpenVidu Meet rooms that are currently active.
tags:
- Room
security:
- apiKeyInHeader: []
responses:
'200':
description: Successfully retrieved the list of OpenVidu Meet rooms
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/OpenViduMeetRoom'
'401':
description: Unauthorized — The API key is missing or invalid
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
message: 'Unauthorized'
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
code: 500
message: 'Internal server error'
put:
operationId: updateRoom
summary: Update an OpenVidu Meet room
description: >
Updates the preferences of an OpenVidu Meet room with the specified room name.
tags:
- Room
security:
- apiKeyInHeader: []
parameters:
- name: roomName
in: path
required: true
description: The name of the room to update
schema:
type: string
requestBody:
description: Room preferences to update
content:
application/json:
schema:
$ref: '#/components/schemas/RoomPreferences'
examples:
default:
value:
preferences:
chatPreferences:
enabled: true
recordingPreferences:
enabled: true
virtualBackgroundPreferences:
enabled: true
responses:
'200':
description: Successfully updated the OpenVidu Meet room
content:
application/json:
schema:
$ref: '#/components/schemas/OpenViduMeetRoom'
'401':
description: Unauthorized — The API key is missing or invalid
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
message: 'Unauthorized'
'404':
description: Room not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
code: 404
message: 'Room not found'
'422':
description: Unprocessable Entity — The request body is invalid
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: 'Unprocessable Entity'
message:
type: string
example: 'Invalid request body'
details:
type: array
items:
type: object
properties:
field:
type: string
example: 'recordingPreferences'
message:
type: string
example: 'Expected boolean, received string'
/rooms/{roomName}:
get:
operationId: getRoom
summary: Get details of an OpenVidu Meet room
description: >
Retrieves the details of an OpenVidu Meet room with the specified room name.
tags:
- Room
security:
- apiKeyInHeader: []
parameters:
- name: roomName
in: path
required: true
description: The name of the room to retrieve
schema:
type: string
responses:
'200':
description: Successfully retrieved the OpenVidu Meet room
content:
application/json:
schema:
$ref: '#/components/schemas/OpenViduMeetRoom'
'401':
description: Unauthorized — The API key is missing or invalid
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
message: 'Unauthorized'
'404':
description: Room not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
code: 404
message: 'Room not found'
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
code: 500
message: 'Internal server error'
delete:
operationId: deleteRoom
summary: Delete an OpenVidu Meet room
description: >
Deletes an OpenVidu Meet room with the specified room name.
tags:
- Room
security:
- apiKeyInHeader: []
parameters:
- name: roomName
in: path
required: true
description: The name of the room to delete
schema:
type: string
responses:
'204':
description: Successfully deleted the OpenVidu Meet room
'401':
description: Unauthorized — The API key is missing or invalid
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
message: 'Unauthorized'
'404':
description: Room not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
code: 404
message: 'Room not found'
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
code: 500
message: 'Internal server error'
/participants/token:
post:
operationId: generateParticipantToken
summary: Generate a token for a participant
description: >
Generates a token for a participant to join an OpenVidu Meet room.
tags:
- Participant
requestBody:
description: Participant details
content:
application/json:
schema:
type: object
required:
- roomName
- participantName
- secret
properties:
roomName:
type: string
example: 'OpenVidu-123456'
description: >
The name of the room to join.
participantName:
type: string
example: 'Alice'
description: >
The name of the participant.
secret:
type: string
example: 'abc123456'
description: >
The secret token from the room Url
responses:
'200':
description: Successfully generated the participant token
content:
application/json:
schema:
type: object
properties:
token:
type: string
example: 'token_123456'
description: >
The token to authenticate the participant.
'404':
description: Room not found
content:
application/json:
schema:
type: object
properties:
name:
type: string
example: 'Room not found'
message:
type: string
example: 'The room does not exist'
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
message: 'Internal server error'
/participants/{participantName}:
delete:
operationId: disconnectParticipant
summary: Delete a participant from a room
description: >
Deletes a participant from an OpenVidu Meet room.
tags:
- Participant
security:
- apiKeyInHeader: []
parameters:
- name: participantName
in: path
required: true
description: The name of the participant to delete
schema:
type: string
- name: roomName
in: query
required: true
description: The name of the room from which to delete the participant
schema:
type: string
responses:
'204':
description: Successfully disconnect the participant
'404':
description: Participant not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
code: 404
message: 'Participant not found'
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
code: 500
message: 'Internal server error'
components:
securitySchemes:
apiKeyInHeader:
type: apiKey
name: X-API-KEY
in: header
description: >
The API key to authenticate the request. This key is required to access the OpenVidu Meet API.
jwtInCookie:
type: apiKey
name: OvMeetAccessToken
in: cookie
description: >
The JWT token to authenticate the request in case of consuming the API from the OpenVidu Meet admin console.
schemas:
OpenViduMeetRoomOptions:
type: object
required:
- expirationDate
properties:
expirationDate:
type: number
example: 1620000000000
description: >
The expiration date of the room in milliseconds since the Unix epoch.
After this date, the room will be closed and no new participants will be allowed to join.
roomNamePrefix:
type: string
example: 'OpenVidu'
description: >
A prefix to be used for the room name. The room name will be generated by appending a random
alphanumeric string to this prefix.
maxParticipants:
type: integer
example: 10
description: >
The maximum number of participants allowed in the room. If the number of participants exceeds
this limit, new participants will not be allowed to join.
preferences:
$ref: '#/components/schemas/RoomPreferences'
description: >
The preferences for the room.
RoomPreferences:
type: object
properties:
chatPreferences:
$ref: '#/components/schemas/ChatPreferences'
description: >
Preferences for the chat feature in the room.
recordingPreferences:
$ref: '#/components/schemas/RecordingPreferences'
description: >
Preferences for recording the room.
virtualBackgroundPreferences:
$ref: '#/components/schemas/VirtualBackgroundPreferences'
description: >
Preferences for virtual background in the room.
ChatPreferences:
type: object
properties:
enabled:
type: boolean
default: true
example: true
description: >
If true, the room will be allowed to send and receive chat messages.
RecordingPreferences:
type: object
properties:
enabled:
type: boolean
default: true
example: true
description: >
If true, the room will be allowed to record the video of the participants.
VirtualBackgroundPreferences:
type: object
properties:
enabled:
type: boolean
default: true
example: true
description: >
If true, the room will be allowed to use virtual background.
OpenViduMeetRoom:
type: object
properties:
roomName:
type: string
example: 'OpenVidu-123456'
description: >
The name of the room. This name is generated by appending a random alphanumeric string to the
room name prefix specified in the request.
creationDate:
type: number
example: 1620000000000
description: >
The creation date of the room in milliseconds since the Unix epoch.
expirationDate:
type: number
example: 1620000000000
description: >
The expiration date of the room in milliseconds since the Unix epoch.
After this date, the room will be closed and no new participants will be allowed to join.
roomNamePrefix:
type: string
example: 'OpenVidu'
description: >
The prefix used for the room name. The room name is generated by appending a random alphanumeric
string to this prefix.
preferences:
$ref: '#/components/schemas/RoomPreferences'
description: >
The preferences for the room.
maxParticipants:
type: integer
example: 10
description: >
The maximum number of participants allowed in the room. If the number of participants exceeds
this limit, new participants will not be allowed to join.
moderatorURL:
type: string
example: 'http://localhost:6080/meet/OpenVidu-123456/?secret=tok_123456'
description: >
The URL for the moderator to join the room. The moderator has special permissions to manage the
room and participants.
publisherURL:
type: string
example: 'http://localhost:6080/meet/OpenVidu-123456/?secret=tok_123456'
description: >
The URL for the publisher to join the room. The publisher has permissions to publish audio and
video streams to the room.
viewerURL:
type: string
example: 'http://localhost:6080/meet/OpenVidu-123456/?secret=tok_123456'
description: >
The URL for the viewer to join the room. The viewer has read-only permissions to watch the room
and participants.
Error:
type: object
required:
- message
properties:
message:
type: string

12629
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

91
backend/package.json Normal file
View File

@ -0,0 +1,91 @@
{
"name": "openvidu-meet-backend",
"version": "3.0.0-beta3",
"description": "OpenVidu Meet Backend",
"author": "OpenVidu",
"license": "Apache-2.0",
"homepage": "https://github.com/OpenVidu/openvidu-meet#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/OpenVidu/openvidu-meet.git"
},
"bugs": {
"url": "https://github.com/OpenVidu/openvidu-meet/issues"
},
"keywords": [
"openvidu",
"webrtc",
"openvidu-meet"
],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"files": [
"dist",
"src",
"openapi",
"package.json"
],
"scripts": {
"build:prod": "tsc",
"start:prod": "node dist/src/server.js",
"start:dev": "nodemon",
"package:build": "npm run build:prod && npm pack",
"lib:sync-pro": "npm run package:build && cp openvidu-meet-server-*.tgz ../../openvidu-meet-pro/backend",
"test:embedded-auth-api": "node --experimental-vm-modules node_modules/.bin/jest --forceExit tests/api/embedded/participants.test.ts",
"test:integration-webhooks": "node --experimental-vm-modules node_modules/.bin/jest --forceExit tests/services/openvidu-webhook.service.test.ts",
"lint:fix": "eslint src --fix",
"format:code": "prettier --ignore-path .gitignore --write '**/*.{ts,js,json,md}'"
},
"dependencies": {
"@aws-sdk/client-s3": "3.673.0",
"chalk": "5.4.1",
"cookie-parser": "1.4.7",
"cors": "2.8.5",
"cron": "^4.1.0",
"dotenv": "16.4.7",
"express": "4.21.2",
"express-basic-auth": "1.2.1",
"express-openapi-validator": "^5.4.2",
"express-rate-limit": "^7.5.0",
"inversify": "^6.2.1",
"ioredis": "^5.4.2",
"livekit-server-sdk": "2.6.2",
"ms": "2.1.3",
"redlock": "git+https://github.com/mike-marcacci/node-redlock.git",
"reflect-metadata": "^0.2.2",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"winston": "3.14.2",
"yamljs": "^0.3.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@openapitools/openapi-generator-cli": "^2.16.3",
"@types/cookie-parser": "1.4.7",
"@types/cors": "2.8.17",
"@types/express": "4.17.21",
"@types/jest": "^29.5.14",
"@types/ms": "2.1.0",
"@types/node": "^20.12.14",
"@types/supertest": "^6.0.2",
"@types/swagger-ui-express": "^4.1.6",
"@types/validator": "^13.12.2",
"@types/yamljs": "^0.2.34",
"@typescript-eslint/eslint-plugin": "6.7.5",
"@typescript-eslint/parser": "6.7.5",
"cross-env": "7.0.3",
"eslint": "8.50.0",
"eslint-config-prettier": "9.0.0",
"jest": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"nodemon": "3.1.9",
"openapi-zod-client": "1.18.2",
"prettier": "3.3.3",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-jest-resolver": "^2.0.1",
"ts-node": "10.9.2",
"typescript": "5.4.5"
}
}

View File

@ -0,0 +1,56 @@
import { Container } from 'inversify';
import {
AuthService,
GlobalPreferencesService,
GlobalPreferencesStorageFactory,
LiveKitService,
LivekitWebhookService,
LoggerService,
MutexService,
OpenViduWebhookService,
ParticipantService,
RecordingService,
RedisService,
RoomService,
S3PreferenceStorage,
S3Service,
SystemEventService,
TaskSchedulerService,
TokenService
} from '../services/index.js';
const container: Container = new Container();
/**
* Registers all necessary dependencies in the container.
*
* This function is responsible for registering services and other dependencies
* that are required by the application. It ensures that the dependencies are
* available for injection throughout the application.
*
*/
const registerDependencies = () => {
console.log('Registering CE dependencies');
container.bind(SystemEventService).toSelf().inSingletonScope();
container.bind(MutexService).toSelf().inSingletonScope();
container.bind(TaskSchedulerService).toSelf().inSingletonScope();
container.bind(LoggerService).toSelf().inSingletonScope();
container.bind(AuthService).toSelf().inSingletonScope();
container.bind(TokenService).toSelf().inSingletonScope();
container.bind(LiveKitService).toSelf().inSingletonScope();
container.bind(RoomService).toSelf().inSingletonScope();
container.bind(OpenViduWebhookService).toSelf().inSingletonScope();
container.bind(RedisService).toSelf().inSingletonScope();
container.bind(S3Service).toSelf().inSingletonScope();
container.bind(RecordingService).toSelf().inSingletonScope();
container.bind(LivekitWebhookService).toSelf().inSingletonScope();
container.bind(GlobalPreferencesService).toSelf().inSingletonScope();
container.bind(ParticipantService).toSelf().inSingletonScope();
container.bind(S3PreferenceStorage).toSelf().inSingletonScope();
container.bind(GlobalPreferencesStorageFactory).toSelf().inSingletonScope();
};
export { injectable, inject } from 'inversify';
export { container, registerDependencies };

View File

@ -0,0 +1 @@
export * from './dependency-injector.config.js';

View File

@ -0,0 +1,108 @@
import { container } from '../config/dependency-injector.config.js';
import { Request, Response } from 'express';
import { AuthService } from '../services/auth.service.js';
import { TokenService } from '../services/token.service.js';
import { LoggerService } from '../services/logger.service.js';
import {
ACCESS_TOKEN_COOKIE_NAME,
MEET_ADMIN_USER,
MEET_API_BASE_PATH_V1,
REFRESH_TOKEN_COOKIE_NAME
} from '../environment.js';
import { ClaimGrants } from 'livekit-server-sdk';
export const login = (req: Request, res: Response) => {
const logger = container.get(LoggerService);
logger.verbose('Login request received');
const { username, password } = req.body;
if (!username || !password) {
logger.warn('Missing username or password');
return res.status(400).json({ message: 'Missing username or password' });
}
const authService = container.get(AuthService);
const authenticated = authService.authenticateUser(username, password);
if (!authenticated) {
logger.warn('Login failed');
return res.status(401).json({ message: 'Login failed' });
}
return res.status(200).json({ message: 'Login succeeded' });
};
export const logout = (req: Request, res: Response) => {
return res.status(200).json({ message: 'Logout successful' });
};
export const adminLogin = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
logger.verbose('Admin login request received');
const { username, password } = req.body;
const authService = container.get(AuthService);
const authenticated = authService.authenticateAdmin(username, password);
if (!authenticated) {
logger.warn(`Admin login failed for username: ${username}`);
return res.status(404).json({ message: 'Admin login failed. Invalid username or password' });
}
try {
const tokenService = container.get(TokenService);
const accessToken = await tokenService.generateAccessToken(username);
const refreshToken = await tokenService.generateRefreshToken(username);
res.cookie(ACCESS_TOKEN_COOKIE_NAME, accessToken, tokenService.getAccessTokenCookieOptions());
res.cookie(REFRESH_TOKEN_COOKIE_NAME, refreshToken, tokenService.getRefreshTokenCookieOptions());
logger.info(`Admin login succeeded for username: ${username}`);
return res.status(200).json({ message: 'Admin login succeeded' });
} catch (error) {
logger.error('Error generating admin token' + error);
return res.status(500).json({ message: 'Internal server error' });
}
};
export const adminLogout = (req: Request, res: Response) => {
res.clearCookie(ACCESS_TOKEN_COOKIE_NAME);
res.clearCookie(REFRESH_TOKEN_COOKIE_NAME, {
path: `${MEET_API_BASE_PATH_V1}/auth/admin`
});
return res.status(200).json({ message: 'Logout successful' });
};
export const adminRefresh = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
logger.verbose('Admin refresh request received');
const refreshToken = req.cookies[REFRESH_TOKEN_COOKIE_NAME];
if (!refreshToken) {
logger.warn('No refresh token provided');
return res.status(400).json({ message: 'No refresh token provided' });
}
const tokenService = container.get(TokenService);
let payload: ClaimGrants;
try {
payload = await tokenService.verifyToken(refreshToken);
} catch (error) {
logger.error('Error verifying refresh token' + error);
return res.status(400).json({ message: 'Invalid refresh token' });
}
if (payload.sub !== MEET_ADMIN_USER) {
logger.warn('Invalid refresh token subject');
return res.status(403).json({ message: 'Invalid refresh token subject' });
}
try {
const accessToken = await tokenService.generateAccessToken(MEET_ADMIN_USER);
res.cookie(ACCESS_TOKEN_COOKIE_NAME, accessToken, tokenService.getAccessTokenCookieOptions());
logger.info(`Admin refresh succeeded for username: ${MEET_ADMIN_USER}`);
return res.status(200).json({ message: 'Admin refresh succeeded' });
} catch (error) {
logger.error('Error refreshing admin token' + error);
return res.status(500).json({ message: 'Internal server error' });
}
};

View File

@ -0,0 +1,13 @@
import { Request, Response } from 'express';
export const updateAppearancePreferences = async (req: Request, res: Response) => {
return res
.status(402)
.json({ message: 'Storing appearance preference is a PRO feature. Please, Updrade to OpenVidu PRO' });
};
export const getAppearancePreferences = async (req: Request, res: Response) => {
return res
.status(402)
.json({ message: 'Getting appearance preference is a PRO feature. Please, Updrade to OpenVidu PRO' });
};

View File

@ -0,0 +1,55 @@
import { container } from '../../config/dependency-injector.config.js';
import { Request, Response } from 'express';
import { LoggerService } from '../../services/logger.service.js';
import { GlobalPreferencesService } from '../../services/preferences/index.js';
import { OpenViduMeetError } from '../../models/error.model.js';
export const updateRoomPreferences = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
logger.verbose(`Updating room preferences: ${JSON.stringify(req.body)}`);
const { roomName, roomPreferences } = req.body;
try {
const preferenceService = container.get(GlobalPreferencesService);
preferenceService.validateRoomPreferences(roomPreferences);
const savedPreferences = await preferenceService.updateOpenViduRoomPreferences(roomName, roomPreferences);
return res
.status(200)
.json({ message: 'Room preferences updated successfully.', preferences: savedPreferences });
} catch (error) {
if (error instanceof OpenViduMeetError) {
logger.error(`Error saving room preferences: ${error.message}`);
return res.status(error.statusCode).json({ name: error.name, message: error.message });
}
logger.error('Error saving room preferences:' + error);
return res.status(500).json({ message: 'Error saving room preferences', error });
}
};
export const getRoomPreferences = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
try {
const roomName = req.params.roomName;
const preferenceService = container.get(GlobalPreferencesService);
const preferences = await preferenceService.getOpenViduRoomPreferences(roomName);
if (!preferences) {
return res.status(404).json({ message: 'Room preferences not found' });
}
return res.status(200).json(preferences);
} catch (error) {
if (error instanceof OpenViduMeetError) {
logger.error(`Error getting room preferences: ${error.message}`);
return res.status(error.statusCode).json({ name: error.name, message: error.message });
}
logger.error('Error getting room preferences:' + error);
return res.status(500).json({ message: 'Error fetching room preferences from database', error });
}
};

View File

@ -0,0 +1,5 @@
export * from './auth.controller.js';
export * from './recording.controller.js';
export * from './room.controller.js';
export * from './participant.controller.js';
export * from './livekit-webhook.controller.js';

View File

@ -0,0 +1,53 @@
import { Request, Response } from 'express';
import { LoggerService } from '../services/logger.service.js';
import { LivekitWebhookService } from '../services/livekit-webhook.service.js';
import { RoomService } from '../services/room.service.js';
import { WebhookEvent } from 'livekit-server-sdk';
import { OpenViduWebhookService } from '../services/openvidu-webhook.service.js';
import { container } from '../config/dependency-injector.config.js';
export const lkWebhookHandler = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
try {
const lkWebhookService = container.get(LivekitWebhookService);
const ovWebhookService = container.get(OpenViduWebhookService);
const webhookEvent: WebhookEvent = await lkWebhookService.getEventFromWebhook(
req.body,
req.get('Authorization')!
);
const { event: eventType, egressInfo, room, participant } = webhookEvent;
const belongsToOpenViduMeet = await lkWebhookService.webhookEventBelongsToOpenViduMeet(webhookEvent);
if (!belongsToOpenViduMeet) {
logger.verbose(`Skipping webhook, event is not related to OpenVidu Meet: ${eventType}`);
return res.status(200).send();
}
logger.verbose(`Received webhook event: ${eventType}`);
switch (eventType) {
case 'egress_started':
case 'egress_updated':
await lkWebhookService.handleEgressUpdated(egressInfo!);
break;
case 'egress_ended':
await lkWebhookService.handleEgressEnded(egressInfo!);
break;
case 'participant_joined':
await lkWebhookService.handleParticipantJoined(room!, participant!);
break;
case 'room_finished':
await ovWebhookService.sendRoomFinishedWebhook(room!);
break;
default:
break;
}
} catch (error) {
logger.error(`Error handling webhook event: ${error}`);
}
return res.status(200).send();
};

View File

@ -0,0 +1,69 @@
import { container } from '../config/dependency-injector.config.js';
import { Request, Response } from 'express';
import { LoggerService } from '../services/logger.service.js';
import { TokenOptions } from '@typings-ce';
import { OpenViduMeetError } from '../models/index.js';
import { ParticipantService } from '../services/participant.service.js';
import { RoomService } from '../services/room.service.js';
export const generateParticipantToken = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const tokenOptions: TokenOptions = req.body;
const { roomName, secret, participantName } = tokenOptions;
try {
const roomService = container.get(RoomService);
const participantService = container.get(ParticipantService);
// Check if participant with same participantName exists in the room
const participantExists = await participantService.participantExists(roomName, participantName);
if (participantExists) {
logger.verbose(`Participant ${participantName} already exists in room ${roomName}`);
return res.status(409).json({ message: 'Participant already exists' });
}
logger.verbose(`Generating participant token for room ${roomName}`);
const secretRole = await roomService.getRoomSecretRole(roomName, secret);
const token = await participantService.generateParticipantToken(secretRole, tokenOptions);
// TODO: Set the participant token in a cookie
// res.cookie('ovParticipantToken', token, { httpOnly: true, expires: tokenTtl });
logger.verbose(`Participant token generated for room ${roomName}`);
return res.status(200).json({ token });
} catch (error) {
logger.error(`Error generating participant token for room: ${roomName}`);
return handleError(res, error);
}
};
export const deleteParticipant = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const participantService = container.get(ParticipantService);
const { participantName } = req.params;
const roomName: string = req.query.roomName as string;
try {
await participantService.deleteParticipant(participantName, roomName);
res.status(200).json({ message: 'Participant deleted' });
} catch (error) {
logger.error(`Error deleting participant from room: ${roomName}`);
return handleError(res, error);
}
};
const handleError = (res: Response, error: OpenViduMeetError | unknown) => {
const logger = container.get(LoggerService);
logger.error(String(error));
if (error instanceof OpenViduMeetError) {
res.status(error.statusCode).json({ name: error.name, message: error.message });
} else {
res.status(500).json({
name: 'Participant Error',
message: 'Internal server error. Participant operation failed'
});
}
};

View File

@ -0,0 +1,162 @@
import { Request, Response } from 'express';
import { LoggerService } from '../services/logger.service.js';
import { OpenViduMeetError } from '../models/error.model.js';
import { RecordingService } from '../services/recording.service.js';
import { container } from '../config/dependency-injector.config.js';
export const startRecording = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const roomName = req.body.roomName;
if (!roomName) {
return res.status(400).json({ name: 'Recording Error', message: 'Room name is required for this operation' });
}
try {
logger.info(`Starting recording in ${roomName}`);
const recordingService = container.get(RecordingService);
const recordingInfo = await recordingService.startRecording(roomName);
return res.status(200).json(recordingInfo);
} catch (error) {
if (error instanceof OpenViduMeetError) {
logger.error(`Error starting recording: ${error.message}`);
return res.status(error.statusCode).json({ name: error.name, message: error.message });
}
return res.status(500).json({ name: 'Recording Error', message: 'Failed to start recording' });
}
};
export const stopRecording = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const recordingId = req.params.recordingId;
if (!recordingId) {
return res
.status(400)
.json({ name: 'Recording Error', message: 'Recording ID is required for this operation' });
}
try {
logger.info(`Stopping recording ${recordingId}`);
const recordingService = container.get(RecordingService);
const recordingInfo = await recordingService.stopRecording(recordingId);
return res.status(200).json(recordingInfo);
} catch (error) {
if (error instanceof OpenViduMeetError) {
logger.error(`Error stopping recording: ${error.message}`);
return res.status(error.statusCode).json({ name: error.name, message: error.message });
}
return res.status(500).json({ name: 'Recording Error', message: 'Unexpected error stopping recording' });
}
};
/**
* Endpoint only available for the admin user
* !WARNING: This will be removed in future versions
*/
export const getAllRecordings = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
try {
logger.info('Getting all recordings');
const recordingService = container.get(RecordingService);
// const continuationToken = req.query.continuationToken as string;
const response = await recordingService.getAllRecordings();
return res
.status(200)
.json({ recordings: response.recordingInfo, continuationToken: response.continuationToken });
} catch (error) {
if (error instanceof OpenViduMeetError) {
logger.error(`Error getting all recordings: ${error.message}`);
return res.status(error.statusCode).json({ name: error.name, message: error.message });
}
return res.status(500).json({ name: 'Recording Error', message: 'Unexpected error getting recordings' });
}
};
export const streamRecording = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const recordingId = req.params.recordingId;
const range = req.headers.range;
if (!recordingId) {
return res
.status(400)
.json({ name: 'Recording Error', message: 'Recording ID is required for this operation' });
}
try {
logger.info(`Streaming recording ${recordingId}`);
const recordingService = container.get(RecordingService);
const { fileSize, fileStream, start, end } = await recordingService.getRecordingAsStream(recordingId, range);
if (range && fileSize && start !== undefined && end !== undefined) {
const contentLength = end - start + 1;
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': contentLength,
'Content-Type': 'video/mp4'
});
fileStream.on('error', (streamError) => {
logger.error(`Error while streaming the file: ${streamError.message}`);
res.end();
});
fileStream.pipe(res).on('finish', () => res.end());
} else {
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Type', 'video/mp4');
if (fileSize) res.setHeader('Content-Length', fileSize);
fileStream.pipe(res).on('finish', () => res.end());
}
} catch (error) {
if (error instanceof OpenViduMeetError) {
logger.error(`Error streaming recording: ${error.message}`);
return res.status(error.statusCode).json({ name: error.name, message: error.message });
}
return res.status(500).json({ name: 'Recording Error', message: 'Unexpected error streaming recording' });
}
};
export const deleteRecording = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const recordingId = req.params.recordingId;
if (!recordingId) {
return res
.status(400)
.json({ name: 'Recording Error', message: 'Recording ID is required for this operation' });
}
try {
logger.info(`Deleting recording ${recordingId}`);
const recordingService = container.get(RecordingService);
const isRequestedByAdmin = req.url.includes('admin');
const recordingInfo = await recordingService.deleteRecording(recordingId, isRequestedByAdmin);
return res.status(204).json(recordingInfo);
} catch (error) {
if (error instanceof OpenViduMeetError) {
logger.error(`Error deleting recording: ${error.message}`);
return res.status(error.statusCode).json({ name: error.name, message: error.message });
}
return res.status(500).json({ name: 'Recording Error', message: 'Unexpected error deleting recording' });
}
};

View File

@ -0,0 +1,118 @@
import { container } from '../config/dependency-injector.config.js';
import { Request, Response } from 'express';
import { LoggerService } from '../services/logger.service.js';
import { OpenViduMeetError } from '../models/error.model.js';
import { RoomService } from '../services/room.service.js';
import { OpenViduMeetRoomOptions } from '@typings-ce';
export const createRoom = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const roomService = container.get(RoomService);
const options: OpenViduMeetRoomOptions = req.body;
try {
logger.verbose(`Creating room with options '${JSON.stringify(options)}'`);
const baseUrl = `${req.protocol}://${req.get('host')}`;
const room = await roomService.createRoom(baseUrl, options);
return res.status(200).json(room);
} catch (error) {
logger.error(`Error creating room with options '${JSON.stringify(options)}'`);
handleError(res, error);
}
};
export const getRooms = async (_req: Request, res: Response) => {
const logger = container.get(LoggerService);
try {
logger.verbose('Getting rooms');
const roomService = container.get(RoomService);
const rooms = await roomService.listOpenViduRooms();
return res.status(200).json(rooms);
} catch (error) {
logger.error('Error getting rooms');
handleError(res, error);
}
};
export const getRoom = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const { roomName } = req.params;
try {
logger.verbose(`Getting room with id '${roomName}'`);
const roomService = container.get(RoomService);
const room = await roomService.getOpenViduRoom(roomName);
return res.status(200).json(room);
} catch (error) {
logger.error(`Error getting room with id '${roomName}'`);
handleError(res, error);
}
};
export const deleteRooms = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const roomService = container.get(RoomService);
const { roomName } = req.params;
const { roomNames } = req.body;
const roomsToDelete = roomName ? [roomName] : roomNames;
// TODO: Validate roomNames with ZOD
if (!Array.isArray(roomsToDelete) || roomsToDelete.length === 0) {
return res.status(400).json({ error: 'roomNames must be a non-empty array' });
}
try {
logger.verbose(`Deleting rooms: ${roomsToDelete.join(', ')}`);
await roomService.deleteRooms(roomsToDelete);
logger.info(`Rooms deleted: ${roomsToDelete.join(', ')}`);
return res.status(200).json({ message: 'Rooms deleted', deletedRooms: roomsToDelete });
} catch (error) {
logger.error(`Error deleting rooms: ${roomsToDelete.join(', ')}`);
handleError(res, error);
}
};
export const updateRoomPreferences = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
logger.verbose(`Updating room preferences: ${JSON.stringify(req.body)}`);
// const { roomName, roomPreferences } = req.body;
// try {
// const preferenceService = container.get(GlobalPreferencesService);
// preferenceService.validateRoomPreferences(roomPreferences);
// const savedPreferences = await preferenceService.updateOpenViduRoomPreferences(roomName, roomPreferences);
// return res
// .status(200)
// .json({ message: 'Room preferences updated successfully.', preferences: savedPreferences });
// } catch (error) {
// if (error instanceof OpenViduCallError) {
// logger.error(`Error saving room preferences: ${error.message}`);
// return res.status(error.statusCode).json({ name: error.name, message: error.message });
// }
// logger.error('Error saving room preferences:' + error);
// return res.status(500).json({ message: 'Error saving room preferences', error });
// }
};
const handleError = (res: Response, error: OpenViduMeetError | unknown) => {
const logger = container.get(LoggerService);
logger.error(String(error));
if (error instanceof OpenViduMeetError) {
res.status(error.statusCode).json({ name: error.name, message: error.message });
} else {
res.status(500).json({ name: 'Room Error', message: 'Internal server error. Room operation failed' });
}
};

138
backend/src/environment.ts Normal file
View File

@ -0,0 +1,138 @@
import dotenv from 'dotenv';
import chalk from 'chalk';
const envPath = process.env.MEET_CONFIG_DIR
? process.env.MEET_CONFIG_DIR
: process.env.NODE_ENV === 'development'
? '.env.development'
: undefined;
dotenv.config(envPath ? { path: envPath } : {});
export const {
SERVER_PORT = 6080,
SERVER_CORS_ORIGIN = '*',
MEET_NAME_ID = 'openviduMeet',
MEET_API_KEY = 'meet-api-key',
MEET_PRIVATE_ACCESS = 'false',
MEET_USER = 'user',
MEET_SECRET = 'user',
MEET_ADMIN_USER = 'admin',
MEET_ADMIN_SECRET = 'admin',
MEET_ACCESS_TOKEN_EXPIRATION = '2h',
MEET_REFRESH_TOKEN_EXPIRATION = '1d',
MEET_PREFERENCES_STORAGE_MODE = 's3',
MEET_WEBHOOK_ENABLED = 'true',
MEET_WEBHOOK_URL = 'http://localhost:5080/webhook',
MEET_LOG_LEVEL = 'verbose',
// LiveKit configuration
LIVEKIT_URL = 'ws://localhost:7880',
LIVEKIT_URL_PRIVATE = LIVEKIT_URL, // Uses LIVEKIT_URL if not explicitly set
LIVEKIT_API_KEY = 'devkey',
LIVEKIT_API_SECRET = 'secret',
// S3 configuration
MEET_S3_BUCKET = 'openvidu',
MEET_S3_SERVICE_ENDPOINT = 'http://localhost:9000',
MEET_S3_ACCESS_KEY = 'minioadmin',
MEET_S3_SECRET_KEY = 'minioadmin',
MEET_AWS_REGION = 'us-east-1',
MEET_S3_WITH_PATH_STYLE_ACCESS = 'true',
// Redis configuration
MEET_REDIS_HOST: REDIS_HOST = 'localhost',
MEET_REDIS_PORT: REDIS_PORT = 6379,
MEET_REDIS_USERNAME: REDIS_USERNAME = '',
MEET_REDIS_PASSWORD: REDIS_PASSWORD = 'redispassword',
MEET_REDIS_DB: REDIS_DB = '0',
// Redis Sentinel configuration
MEET_REDIS_SENTINEL_HOST_LIST: REDIS_SENTINEL_HOST_LIST = '',
MEET_REDIS_SENTINEL_PASSWORD: REDIS_SENTINEL_PASSWORD = '',
MEET_REDIS_SENTINEL_MASTER_NAME: REDIS_SENTINEL_MASTER_NAME = 'openvidu',
// Deployment configuration
MODULES_FILE = undefined,
MODULE_NAME = 'openviduMeet',
ENABLED_MODULES = ''
} = process.env;
export const MEET_API_BASE_PATH = '/meet/api';
export const MEET_API_BASE_PATH_V1 = MEET_API_BASE_PATH + '/v1';
export const ACCESS_TOKEN_COOKIE_NAME = 'OvMeetAccessToken';
export const REFRESH_TOKEN_COOKIE_NAME = 'OvMeetRefreshToken';
export function checkModuleEnabled() {
if (MODULES_FILE) {
const moduleName = MODULE_NAME;
const enabledModules = ENABLED_MODULES.split(',').map((module) => module.trim());
if (!enabledModules.includes(moduleName)) {
console.error(`Module ${moduleName} is not enabled`);
process.exit(0);
}
}
}
export const logEnvVars = () => {
const credential = chalk.yellow;
const text = chalk.cyanBright;
const enabled = chalk.greenBright;
const disabled = chalk.redBright;
console.log(' ');
console.log('---------------------------------------------------------');
console.log('OpenVidu Meet Server Configuration');
console.log('---------------------------------------------------------');
console.log('SERVICE NAME ID: ', text(MEET_NAME_ID));
console.log('CORS ORIGIN:', text(SERVER_CORS_ORIGIN));
console.log('MEET LOG LEVEL: ', text(MEET_LOG_LEVEL));
console.log('MEET API KEY: ', credential('****' + MEET_API_KEY.slice(-3)));
console.log(
'MEET PRIVATE ACCESS: ',
MEET_PRIVATE_ACCESS === 'true' ? enabled(MEET_PRIVATE_ACCESS) : disabled(MEET_PRIVATE_ACCESS)
);
if (MEET_PRIVATE_ACCESS === 'true') {
console.log('MEET USER: ', credential('****' + MEET_USER.slice(-3)));
console.log('MEET SECRET: ', credential('****' + MEET_SECRET.slice(-3)));
}
console.log('MEET ADMIN USER: ', credential('****' + MEET_ADMIN_USER.slice(-3)));
console.log('MEET ADMIN PASSWORD: ', credential('****' + MEET_ADMIN_SECRET.slice(-3)));
console.log('MEET ACCESS TOKEN EXPIRATION: ', text(MEET_ACCESS_TOKEN_EXPIRATION));
console.log('MEET REFRESH TOKEN EXPIRATION: ', text(MEET_REFRESH_TOKEN_EXPIRATION));
console.log('MEET PREFERENCES STORAGE:', text(MEET_PREFERENCES_STORAGE_MODE));
console.log('---------------------------------------------------------');
console.log('LIVEKIT Configuration');
console.log('---------------------------------------------------------');
console.log('LIVEKIT URL: ', text(LIVEKIT_URL));
console.log('LIVEKIT URL PRIVATE: ', text(LIVEKIT_URL_PRIVATE));
console.log('LIVEKIT API SECRET: ', credential('****' + LIVEKIT_API_SECRET.slice(-3)));
console.log('LIVEKIT API KEY: ', credential('****' + LIVEKIT_API_KEY.slice(-3)));
console.log('---------------------------------------------------------');
console.log('S3 Configuration');
console.log('---------------------------------------------------------');
console.log('MEET S3 BUCKET:', text(MEET_S3_BUCKET));
console.log('MEET S3 SERVICE ENDPOINT:', text(MEET_S3_SERVICE_ENDPOINT));
console.log('MEET S3 ACCESS KEY:', credential('****' + MEET_S3_ACCESS_KEY.slice(-3)));
console.log('MEET S3 SECRET KEY:', credential('****' + MEET_S3_SECRET_KEY.slice(-3)));
console.log('MEET AWS REGION:', text(MEET_AWS_REGION));
console.log('---------------------------------------------------------');
console.log('Redis Configuration');
console.log('---------------------------------------------------------');
console.log('REDIS HOST:', text(REDIS_HOST));
console.log('REDIS PORT:', text(REDIS_PORT));
console.log('REDIS USERNAME:', credential('****' + REDIS_USERNAME.slice(-3)));
console.log('REDIS PASSWORD:', credential('****' + REDIS_PASSWORD.slice(-3)));
if (REDIS_SENTINEL_HOST_LIST !== '') {
console.log('REDIS SENTINEL IS ENABLED');
console.log('REDIS SENTINEL HOST LIST:', text(REDIS_SENTINEL_HOST_LIST));
}
console.log('---------------------------------------------------------');
console.log(' ');
};

View File

@ -0,0 +1 @@
export * from './recording.helper.js';

View File

@ -0,0 +1,167 @@
import { EgressInfo } from 'livekit-server-sdk';
import { RecordingInfo, RecordingOutputMode, RecordingStatus } from '../models/recording.model.js';
import { EgressStatus } from '@livekit/protocol';
import { DataTopic } from '../models/signal.model.js';
export class RecordingHelper {
static toRecordingInfo(egressInfo: EgressInfo): RecordingInfo {
const status = RecordingHelper.extractOpenViduStatus(egressInfo.status);
const size = RecordingHelper.extractSize(egressInfo);
const outputMode = RecordingHelper.extractOutputMode(egressInfo);
const duration = RecordingHelper.extractDuration(egressInfo);
const startedAt = RecordingHelper.extractCreatedAt(egressInfo);
const endTimeInMilliseconds = RecordingHelper.extractEndedAt(egressInfo);
const filename = RecordingHelper.extractFilename(egressInfo);
return {
id: egressInfo.egressId,
roomName: egressInfo.roomName,
roomId: egressInfo.roomId,
outputMode,
status,
filename,
startedAt,
endedAt: endTimeInMilliseconds,
duration,
size
};
}
/**
* Checks if the egress is for recording.
* @param egress - The egress information.
* @returns A boolean indicating if the egress is for recording.
*/
static isRecordingEgress(egress: EgressInfo): boolean {
const { streamResults = [], fileResults = [] } = egress;
return fileResults.length > 0 && streamResults.length === 0;
}
static extractOpenViduStatus(status: EgressStatus | undefined): RecordingStatus {
switch (status) {
case EgressStatus.EGRESS_STARTING:
return RecordingStatus.STARTING;
case EgressStatus.EGRESS_ACTIVE:
return RecordingStatus.STARTED;
case EgressStatus.EGRESS_ENDING:
return RecordingStatus.STOPPED;
case EgressStatus.EGRESS_COMPLETE:
return RecordingStatus.READY;
case EgressStatus.EGRESS_FAILED:
case EgressStatus.EGRESS_ABORTED:
case EgressStatus.EGRESS_LIMIT_REACHED:
return RecordingStatus.FAILED;
default:
return RecordingStatus.FAILED;
}
}
static getDataTopicFromStatus(egressInfo: EgressInfo): DataTopic {
const status = RecordingHelper.extractOpenViduStatus(egressInfo.status);
switch (status) {
case RecordingStatus.STARTING:
return DataTopic.RECORDING_STARTING;
case RecordingStatus.STARTED:
return DataTopic.RECORDING_STARTED;
case RecordingStatus.STOPPED:
case RecordingStatus.READY:
return DataTopic.RECORDING_STOPPED;
case RecordingStatus.FAILED:
return DataTopic.RECORDING_FAILED;
default:
return DataTopic.RECORDING_FAILED;
}
}
/**
* Extracts the OpenVidu output mode based on the provided egress information.
* If the egress information contains roomComposite, it returns RecordingOutputMode.COMPOSED.
* Otherwise, it returns RecordingOutputMode.INDIVIDUAL.
*
* @param egressInfo - The egress information containing the roomComposite flag.
* @returns The extracted OpenVidu output mode.
*/
static extractOutputMode(egressInfo: EgressInfo): RecordingOutputMode {
if (egressInfo.request.case === 'roomComposite') {
return RecordingOutputMode.COMPOSED;
} else {
return RecordingOutputMode.INDIVIDUAL;
}
}
static extractFilename(recordingInfo: RecordingInfo): string | undefined;
static extractFilename(egressInfo: EgressInfo): string | undefined;
static extractFilename(info: RecordingInfo | EgressInfo): string | undefined {
if (!info) return undefined;
if ('request' in info) {
// EgressInfo
return info.fileResults?.[0]?.filename.split('/').pop();
} else {
// RecordingInfo
const { roomName, filename, roomId } = info;
if (!filename) {
return undefined;
}
return roomName ? `${roomName}-${roomId}/${filename}` : filename;
}
}
/**
* Extracts the duration from the given egress information.
* If the duration is not available, it returns 0.
* @param egressInfo The egress information containing the file results.
* @returns The duration in milliseconds.
*/
static extractDuration(egressInfo: EgressInfo): number {
return this.toSeconds(Number(egressInfo.fileResults?.[0]?.duration ?? 0));
}
/**
* Extracts the endedAt value from the given EgressInfo object and converts it to milliseconds.
* If the endedAt value is not provided, it defaults to 0.
*
* @param egressInfo - The EgressInfo object containing the endedAt value.
* @returns The endedAt value converted to milliseconds.
*/
static extractEndedAt(egressInfo: EgressInfo): number {
return this.toMilliseconds(Number(egressInfo.endedAt ?? 0));
}
/**
* Extracts the creation timestamp from the given EgressInfo object.
* If the startedAt property is not defined, it returns 0.
* @param egressInfo The EgressInfo object from which to extract the creation timestamp.
* @returns The creation timestamp in milliseconds.
*/
static extractCreatedAt(egressInfo: EgressInfo): number {
const { startedAt, updatedAt } = egressInfo;
const createdAt = startedAt && Number(startedAt) !== 0 ? startedAt : (updatedAt ?? 0);
return this.toMilliseconds(Number(createdAt));
}
/**
* Extracts the size from the given EgressInfo object.
* If the size is not available, it returns 0.
*
* @param egressInfo - The EgressInfo object to extract the size from.
* @returns The size extracted from the EgressInfo object, or 0 if not available.
*/
static extractSize(egressInfo: EgressInfo): number {
return Number(egressInfo.fileResults?.[0]?.size ?? 0);
}
private static toSeconds(nanoseconds: number): number {
const nanosecondsToSeconds = 1 / 1_000_000_000;
return nanoseconds * nanosecondsToSeconds;
}
private static toMilliseconds(nanoseconds: number): number {
const nanosecondsToMilliseconds = 1 / 1_000_000;
return nanoseconds * nanosecondsToMilliseconds;
}
}

View File

@ -0,0 +1,51 @@
import { OpenViduMeetRoom, OpenViduMeetRoomOptions } from '@typings-ce';
import { CreateOptions } from 'livekit-server-sdk';
import { MEET_NAME_ID } from '../environment.js';
import { uid } from 'uid/single';
export class OpenViduRoomHelper {
/**
* Converts an OpenViduMeetRoom object to an OpenViduMeetRoomOptions object.
*
* @param room - The OpenViduMeetRoom object to convert.
* @returns An OpenViduMeetRoomOptions object containing the same properties as the input room.
*/
static toOpenViduOptions(room: OpenViduMeetRoom): OpenViduMeetRoomOptions {
return {
expirationDate: room.expirationDate,
maxParticipants: room.maxParticipants,
preferences: room.preferences,
roomNamePrefix: room.roomNamePrefix
};
}
static generateLivekitRoomOptions(roomInput: OpenViduMeetRoom | OpenViduMeetRoomOptions): CreateOptions {
const isOpenViduRoom = 'creationDate' in roomInput;
const {
roomName = `${roomInput.roomNamePrefix ?? ''}${uid(15)}`,
expirationDate,
maxParticipants,
creationDate = Date.now()
} = roomInput as OpenViduMeetRoom;
const timeUntilExpiration = this.calculateExpirationTime(expirationDate, creationDate);
return {
name: roomName,
metadata: JSON.stringify({
createdBy: MEET_NAME_ID,
roomOptions: isOpenViduRoom
? OpenViduRoomHelper.toOpenViduOptions(roomInput as OpenViduMeetRoom)
: roomInput
}),
emptyTimeout: timeUntilExpiration,
maxParticipants: maxParticipants || undefined,
departureTimeout: 31_536_000 // 1 year
};
}
private static calculateExpirationTime(expirationDate: number, creationDate: number): number {
return Math.max(0, Math.floor((expirationDate - creationDate) / 1000));
}
}

View File

@ -0,0 +1,96 @@
import { NextFunction, Request, Response } from 'express';
import basicAuth from 'express-basic-auth';
import { TokenService } from '../services/token.service.js';
import {
ACCESS_TOKEN_COOKIE_NAME,
MEET_ADMIN_SECRET,
MEET_ADMIN_USER,
MEET_API_KEY,
MEET_PRIVATE_ACCESS,
MEET_SECRET,
MEET_USER
} from '../environment.js';
import { container } from '../config/dependency-injector.config.js';
// Configure token validation middleware for admin access
export const withAdminValidToken = async (req: Request, res: Response, next: NextFunction) => {
const token = req.cookies[ACCESS_TOKEN_COOKIE_NAME];
if (!token) {
return res.status(401).json({ message: 'Unauthorized' });
}
const tokenService = container.get(TokenService);
try {
const payload = await tokenService.verifyToken(token);
if (payload.sub !== MEET_ADMIN_USER) {
return res.status(403).json({ message: 'Invalid token subject' });
}
} catch (error) {
return res.status(401).json({ message: 'Invalid token' });
}
next();
};
export const withValidApiKey = async (req: Request, res: Response, next: NextFunction) => {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ message: 'Unauthorized' });
}
if (apiKey !== MEET_API_KEY) {
return res.status(401).json({ message: 'Invalid API key' });
}
next();
};
// Configure basic auth middleware for user and admin access
export const withAdminAndUserBasicAuth = (req: Request, res: Response, next: NextFunction) => {
if (MEET_PRIVATE_ACCESS === 'true') {
// Configure basic auth middleware if access is private
const basicAuthMiddleware = basicAuth({
users: {
[MEET_USER]: MEET_SECRET,
[MEET_ADMIN_USER]: MEET_ADMIN_SECRET
},
challenge: true,
unauthorizedResponse: () => 'Unauthorized'
});
return basicAuthMiddleware(req, res, next);
} else {
// Skip basic auth if access is public
next();
}
};
// Configure basic auth middleware for admin access
export const withAdminBasicAuth = basicAuth({
users: {
[MEET_ADMIN_USER]: MEET_ADMIN_SECRET
},
challenge: true,
unauthorizedResponse: () => 'Unauthorized'
});
// Configure basic auth middleware for user access
export const withUserBasicAuth = (req: Request, res: Response, next: NextFunction) => {
if (MEET_PRIVATE_ACCESS === 'true') {
// Configure basic auth middleware if access is private
const basicAuthMiddleware = basicAuth({
users: {
[MEET_USER]: MEET_SECRET
},
challenge: true,
unauthorizedResponse: () => 'Unauthorized'
});
return basicAuthMiddleware(req, res, next);
} else {
// Skip basic auth if access is public
next();
}
};

View File

@ -0,0 +1,18 @@
import { Request, Response, NextFunction } from 'express';
export const mediaTypeValidatorMiddleware = (req: Request, res: Response, next: NextFunction) => {
if (req.method === 'GET') {
return next();
}
const supportedMediaTypes = ['application/json'];
const contentType = req.headers['content-type'];
if (!contentType || !supportedMediaTypes.includes(contentType)) {
return res.status(415).json({
error: `Unsupported Media Type. Supported types: ${supportedMediaTypes.join(', ')}`
});
}
next();
};

View File

@ -0,0 +1,6 @@
export * from './auth.middleware.js';
export * from './recording.middleware.js';
export * from './content-type.middleware.js';
export * from './openapi.middleware.js';
export * from './request-validators/participant-validator.middleware.js';
export * from './request-validators/room-validator.middleware.js';

View File

@ -0,0 +1,13 @@
import * as OpenApiValidator from 'express-openapi-validator';
import { getOpenApiSpecPath } from '../utils/path-utils.js';
import YAML from 'yamljs';
const openapiSpec = YAML.load(getOpenApiSpecPath());
// Validate incoming requests against the OpenAPI specification
export const openapiMiddlewareValidator = OpenApiValidator.middleware({
apiSpec: openapiSpec,
validateRequests: true,
validateResponses: true
});

View File

@ -0,0 +1,35 @@
import { container } from '../config/dependency-injector.config.js';
import { Request, Response, NextFunction } from 'express';
import { GlobalPreferencesService } from '../services/preferences/index.js';
import { RoomPreferences } from '@typings-ce';
import { LoggerService } from '../services/logger.service.js';
export const withRecordingEnabled = async (req: Request, res: Response, next: NextFunction) => {
const logger = container.get(LoggerService);
try {
return next();
// TODO: Think how get the roomName from the request
// const roomName = req.body.roomName;
// const preferenceService = container.get(GlobalPreferencesService);
// const preferences: RoomPreferences | null = await preferenceService.getOpenViduRoomPreferences(roomName);
// if (preferences) {
// const { recordingPreferences } = preferences;
// if (!recordingPreferences.enabled) {
// return res.status(403).json({ message: 'Recording is disabled in this room.' });
// }
// return next();
// }
// logger.error('No room preferences found checking recording preferences. Refusing access.');
// return res.status(403).json({ message: 'Recording is disabled in this room.' });
} catch (error) {
logger.error('Error checking recording preferences:' + error);
return res.status(403).json({ message: 'Recording is disabled in this room.' });
}
};

View File

@ -0,0 +1,51 @@
import { TokenOptions } from '@typings-ce';
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
const ParticipantTokenRequestSchema: z.ZodType<TokenOptions> = z.object({
roomName: z.string().nonempty('Room name is required'),
participantName: z.string().nonempty('Participant name is required'),
secret: z.string().nonempty('Secret is required')
});
const DeleteParticipantSchema = z.object({
roomName: z.string().trim().min(1, 'roomName is required')
});
export const validateParticipantTokenRequest = (req: Request, res: Response, next: NextFunction) => {
const { success, error, data } = ParticipantTokenRequestSchema.safeParse(req.body);
if (!success) {
return rejectRequest(res, error);
}
req.body = data;
next();
};
export const validateParticipantDeletionRequest = (req: Request, res: Response, next: NextFunction) => {
const { success, error, data } = DeleteParticipantSchema.safeParse(req.query);
if (!success) {
return rejectRequest(res, error);
}
req.query = data!;
next();
};
const rejectRequest = (res: Response, error: z.ZodError) => {
const errors = error.errors.map((error) => ({
field: error.path.join('.'),
message: error.message
}));
console.log(errors);
return res.status(422).json({
error: 'Unprocessable Entity',
message: 'Invalid request body',
details: errors
});
};

View File

@ -0,0 +1,72 @@
import {
ChatPreferences,
OpenViduMeetRoomOptions,
RecordingPreferences,
RoomPreferences,
VirtualBackgroundPreferences
} from '@typings-ce';
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
const RecordingPreferencesSchema: z.ZodType<RecordingPreferences> = z.object({
enabled: z.boolean()
});
const ChatPreferencesSchema: z.ZodType<ChatPreferences> = z.object({
enabled: z.boolean()
});
const VirtualBackgroundPreferencesSchema: z.ZodType<VirtualBackgroundPreferences> = z.object({
enabled: z.boolean()
});
const RoomPreferencesSchema: z.ZodType<RoomPreferences> = z.object({
recordingPreferences: RecordingPreferencesSchema,
chatPreferences: ChatPreferencesSchema,
virtualBackgroundPreferences: VirtualBackgroundPreferencesSchema
});
const RoomRequestOptionsSchema: z.ZodType<OpenViduMeetRoomOptions> = z.object({
expirationDate: z
.number()
.positive('Expiration date must be a positive integer')
.min(Date.now(), 'Expiration date must be in the future'),
roomNamePrefix: z
.string()
.transform((val) => val.replace(/\s+/g, '-'))
.optional()
.default(''),
preferences: RoomPreferencesSchema.optional().default({
recordingPreferences: { enabled: true },
chatPreferences: { enabled: true },
virtualBackgroundPreferences: { enabled: true }
}),
maxParticipants: z
.number()
.positive('Max participants must be a positive integer')
.nullable()
.optional()
.default(null)
});
export const validateRoomRequest = (req: Request, res: Response, next: NextFunction) => {
const { success, error, data } = RoomRequestOptionsSchema.safeParse(req.body);
if (!success) {
const errors = error.errors.map((error) => ({
field: error.path.join('.'),
message: error.message
}));
console.log(errors);
return res.status(422).json({
error: 'Unprocessable Entity',
message: 'Invalid request body',
details: errors
});
}
req.body = data;
next();
};

View File

@ -0,0 +1,77 @@
type StatusError = 400 | 404 | 406 | 409 | 422 | 500 | 503;
export class OpenViduMeetError extends Error {
name: string;
statusCode: StatusError;
constructor(error: string, message: string, statusCode: StatusError) {
super(message);
this.name = error;
this.statusCode = statusCode;
}
}
// General errors
export const errorLivekitIsNotAvailable = (): OpenViduMeetError => {
return new OpenViduMeetError('LiveKit Error', 'LiveKit is not available', 503);
};
export const errorS3NotAvailable = (error: any): OpenViduMeetError => {
return new OpenViduMeetError('S3 Error', `S3 is not available ${error}`, 503);
}
export const internalError = (error: any): OpenViduMeetError => {
return new OpenViduMeetError('Unexpected error', `Something went wrong ${error}`, 500);
};
export const errorRequest = (error: string): OpenViduMeetError => {
return new OpenViduMeetError('Wrong request', `Problem with some body parameter. ${error}`, 400);
};
export const errorUnprocessableParams = (error: string): OpenViduMeetError => {
return new OpenViduMeetError('Wrong request', `Some parameters are not valid. ${error}`, 422);
};
// Recording errors
export const errorRecordingNotFound = (recordingId: string): OpenViduMeetError => {
return new OpenViduMeetError('Recording Error', `Recording ${recordingId} not found`, 404);
};
export const errorRecordingNotStopped = (recordingId: string): OpenViduMeetError => {
return new OpenViduMeetError('Recording Error', `Recording ${recordingId} is not stopped yet`, 409);
};
export const errorRecordingNotReady = (recordingId: string): OpenViduMeetError => {
return new OpenViduMeetError('Recording Error', `Recording ${recordingId} is not ready yet`, 409);
};
export const errorRecordingAlreadyStopped = (recordingId: string): OpenViduMeetError => {
return new OpenViduMeetError('Recording Error', `Recording ${recordingId} is already stopped`, 409);
};
export const errorRecordingAlreadyStarted = (roomName: string): OpenViduMeetError => {
return new OpenViduMeetError('Recording Error', `The room '${roomName}' is already being recorded`, 409);
};
// Room errors
export const errorRoomNotFound = (roomName: string): OpenViduMeetError => {
return new OpenViduMeetError('Room Error', `The room '${roomName}' does not exist`, 404);
};
// Participant errors
export const errorParticipantNotFound = (participantName: string, roomName: string): OpenViduMeetError => {
return new OpenViduMeetError(
'Participant Error',
`'${participantName}' not found in room '${roomName}'`,
404
);
};
export const errorParticipantAlreadyExists = (participantName: string, roomName: string): OpenViduMeetError => {
return new OpenViduMeetError(
'Room Error',
`'${participantName}' already exists in room in ${roomName}`,
409
);
};

View File

@ -0,0 +1,5 @@
export * from './recording.model.js';
export * from './room.model.js';
export * from './error.model.js';
export * from './signal.model.js';
export * from './redis.model.js';

View File

@ -0,0 +1,29 @@
export enum RecordingStatus {
STARTING = 'STARTING',
STARTED = 'STARTED',
STOPPING = 'STOPPING',
STOPPED = 'STOPPED',
FAILED = 'FAILED',
READY = 'READY'
}
export enum RecordingOutputMode {
COMPOSED = 'COMPOSED',
INDIVIDUAL = 'INDIVIDUAL'
}
/**
* Interface representing a recording
*/
export interface RecordingInfo {
id: string;
roomName: string;
roomId: string;
outputMode: RecordingOutputMode;
status: RecordingStatus;
filename?: string;
startedAt?: number;
endedAt?: number;
duration?: number;
size?: number;
}

View File

@ -0,0 +1,3 @@
export enum RedisKeyPrefix {
LOCK = 'ov_meet_lock:',
}

View File

@ -0,0 +1,6 @@
import { RecordingInfo } from './recording.model.js';
export interface RoomStatusData {
isRecordingStarted: boolean;
recordingList: RecordingInfo[];
}

View File

@ -0,0 +1,10 @@
export enum DataTopic {
CHAT = 'chat',
RECORDING_STARTING = 'recordingStarting',
RECORDING_STARTED = 'recordingStarted',
RECORDING_STOPPING = 'recordingStopping',
RECORDING_STOPPED = 'recordingStopped',
RECORDING_DELETED = 'recordingDeleted',
RECORDING_FAILED = 'recordingFailed',
ROOM_STATUS = 'roomStatus'
}

View File

@ -0,0 +1,3 @@
export enum OpenViduWebhookEvent {
ROOM_FINISHED = 'room_finished',
}

View File

@ -0,0 +1,27 @@
import { Router, Request, Response } from 'express';
import bodyParser from 'body-parser';
import * as authCtrl from '../controllers/auth.controller.js';
import rateLimit from 'express-rate-limit';
import { withAdminValidToken } from '../middlewares/auth.middleware.js';
export const authRouter = Router();
// Limit login attempts for avoiding brute force attacks
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 min
max: 5,
message: 'Too many login attempts, please try again later.'
});
authRouter.use(bodyParser.urlencoded({ extended: true }));
authRouter.use(bodyParser.json());
// Auth Routes
authRouter.post('/login', authCtrl.login);
authRouter.post('/logout', authCtrl.logout);
authRouter.post('/admin/login', loginLimiter, authCtrl.adminLogin);
authRouter.post('/admin/logout', authCtrl.adminLogout);
authRouter.post('/admin/refresh', authCtrl.adminRefresh);
authRouter.get('/admin/verify', withAdminValidToken, (_req: Request, res: Response) =>
res.status(200).json({ message: 'Valid token' })
);

View File

@ -0,0 +1,15 @@
import { Router } from 'express';
import bodyParser from 'body-parser';
import {
getAppearancePreferences,
updateAppearancePreferences
} from '../controllers/global-preferences/appearance-preferences.controller.js';
import { withAdminValidToken } from '../middlewares/auth.middleware.js';
export const preferencesRouter = Router();
preferencesRouter.use(bodyParser.urlencoded({ extended: true }));
preferencesRouter.use(bodyParser.json());
preferencesRouter.put('/appearance', withAdminValidToken, updateAppearancePreferences);
preferencesRouter.get('/appearance', withAdminValidToken, getAppearancePreferences);

View File

@ -0,0 +1,5 @@
export * from './recording.routes.js';
export * from './global-preferences.routes.js';
export * from './room.routes.js';
export * from './auth.routes.js';
export * from './livekit.routes.js';

View File

@ -0,0 +1,9 @@
import express, { Router } from 'express';
import { lkWebhookHandler } from '../controllers/livekit-webhook.controller.js';
const livekitRouter = Router();
livekitRouter.use(express.raw({ type: 'application/webhook+json' }));
livekitRouter.post('/', lkWebhookHandler);
export { livekitRouter };

View File

@ -0,0 +1,19 @@
import { Router } from 'express';
import bodyParser from 'body-parser';
import * as participantCtrl from '../controllers/participant.controller.js';
import {
validateParticipantDeletionRequest,
validateParticipantTokenRequest
} from '../middlewares/request-validators/participant-validator.middleware.js';
export const participantsInternalRouter = Router();
participantsInternalRouter.use(bodyParser.urlencoded({ extended: true }));
participantsInternalRouter.use(bodyParser.json());
participantsInternalRouter.post('/token', validateParticipantTokenRequest, participantCtrl.generateParticipantToken);
export const participantsRouter = Router();
participantsRouter.use(bodyParser.urlencoded({ extended: true }));
participantsRouter.use(bodyParser.json());
participantsRouter.delete('/:participantName', validateParticipantDeletionRequest, participantCtrl.deleteParticipant);

View File

@ -0,0 +1,21 @@
import { Router } from 'express';
import bodyParser from 'body-parser';
import * as recordingCtrl from '../controllers/recording.controller.js';
import { withUserBasicAuth } from '../middlewares/auth.middleware.js';
import { withRecordingEnabled } from '../middlewares/recording.middleware.js';
export const recordingRouter = Router();
recordingRouter.use(bodyParser.urlencoded({ extended: true }));
recordingRouter.use(bodyParser.json());
// Recording Routes
recordingRouter.post('/', withUserBasicAuth, /*withRecordingEnabled,*/ recordingCtrl.startRecording);
recordingRouter.put('/:recordingId', withUserBasicAuth,/* withRecordingEnabled,*/ recordingCtrl.stopRecording);
recordingRouter.get('/:recordingId/stream', /*withRecordingEnabled,*/ recordingCtrl.streamRecording);
recordingRouter.delete(
'/:recordingId',
withUserBasicAuth,
/*withRecordingEnabled,*/
recordingCtrl.deleteRecording
);

View File

@ -0,0 +1,19 @@
import { Router } from 'express';
import bodyParser from 'body-parser';
import * as roomCtrl from '../controllers/room.controller.js';
import { withUserBasicAuth, withValidApiKey } from '../middlewares/auth.middleware.js';
import { validateRoomRequest } from '../middlewares/request-validators/room-validator.middleware.js';
export const roomRouter = Router();
roomRouter.use(bodyParser.urlencoded({ extended: true }));
roomRouter.use(bodyParser.json());
// Room Routes
roomRouter.post('/', /*withValidApiKey,*/ validateRoomRequest, roomCtrl.createRoom);
roomRouter.get('/', withUserBasicAuth, roomCtrl.getRooms);
roomRouter.get('/:roomName', withUserBasicAuth, roomCtrl.getRoom);
roomRouter.delete('/:roomName', withUserBasicAuth, roomCtrl.deleteRooms);
// Room preferences
roomRouter.put('/', /*withAdminBasicAuth,*/ roomCtrl.updateRoomPreferences);

105
backend/src/server.ts Normal file
View File

@ -0,0 +1,105 @@
import express, { Request, Response, Express } from 'express';
import cors from 'cors';
import chalk from 'chalk';
import YAML from 'yamljs';
import swaggerUi from 'swagger-ui-express';
import { registerDependencies, container } from './config/dependency-injector.config.js';
import {
SERVER_PORT,
SERVER_CORS_ORIGIN,
logEnvVars,
MEET_API_BASE_PATH_V1,
MEET_API_BASE_PATH
} from './environment.js';
import { getOpenApiSpecPath, indexHtmlPath, publicFilesPath, webcomponentBundlePath } from './utils/path-utils.js';
import { authRouter, livekitRouter, preferencesRouter, recordingRouter, roomRouter } from './routes/index.js';
import { GlobalPreferencesService, RoomService } from './services/index.js';
import { participantsInternalRouter, participantsRouter } from './routes/participants.routes.js';
import cookieParser from 'cookie-parser';
const createApp = () => {
const app: Express = express();
const openapiSpec = YAML.load(getOpenApiSpecPath());
// Enable CORS support
if (SERVER_CORS_ORIGIN) {
app.use(
cors({
origin: SERVER_CORS_ORIGIN,
credentials: true
})
);
}
// Serve static files
app.use(express.static(publicFilesPath));
app.use(express.json());
app.use(cookieParser());
app.use(`${MEET_API_BASE_PATH_V1}/docs`, swaggerUi.serve, swaggerUi.setup(openapiSpec));
app.use(`${MEET_API_BASE_PATH_V1}/rooms`, /*mediaTypeValidatorMiddleware,*/ roomRouter);
app.use(`${MEET_API_BASE_PATH_V1}/recordings`, /*mediaTypeValidatorMiddleware,*/ recordingRouter);
app.use(`${MEET_API_BASE_PATH_V1}/auth`, /*mediaTypeValidatorMiddleware,*/ authRouter);
app.use(`${MEET_API_BASE_PATH_V1}/participants`, participantsRouter);
// TODO: This route should be part of the rooms router
app.use(`${MEET_API_BASE_PATH_V1}/preferences`, /*mediaTypeValidatorMiddleware,*/ preferencesRouter);
// Internal routes
app.use(`${MEET_API_BASE_PATH}/participants`, participantsInternalRouter);
app.use('/meet/health', (_req: Request, res: Response) => res.status(200).send('OK'));
app.use('/livekit/webhook', livekitRouter);
app.use('/meet/livekit/webhook', livekitRouter);
// Serve OpenVidu Meet webcomponent bundle file
app.get('/meet/v1/openvidu-meet.js', (_req: Request, res: Response) => res.sendFile(webcomponentBundlePath));
// Serve OpenVidu Meet index.html file for all non-API routes
app.get(/^(?!\/api).*$/, (_req: Request, res: Response) => res.sendFile(indexHtmlPath));
// Catch all other routes and return 404
app.use((_req: Request, res: Response) => res.status(404).json({ error: 'Not found' }));
return app;
};
const initializeGlobalPreferences = async () => {
const globalPreferencesService = container.get(GlobalPreferencesService);
await globalPreferencesService.ensurePreferencesInitialized();
};
const startServer = (app: express.Application) => {
app.listen(SERVER_PORT, async () => {
console.log(' ');
console.log('---------------------------------------------------------');
console.log(' ');
console.log('OpenVidu Meet is listening on port', chalk.cyanBright(SERVER_PORT));
console.log('REST API Docs: ', chalk.cyanBright(`http://localhost:${SERVER_PORT}/meet/api/v1/docs`));
logEnvVars();
await Promise.all([initializeGlobalPreferences(), container.get(RoomService).initialize()]);
});
};
/**
* Determines if the current module is the main entry point of the application.
* @returns {boolean} True if this module is the main entry point, false otherwise.
*/
const isMainModule = (): boolean => {
const importMetaUrl = import.meta.url;
let processArgv1 = process.argv[1];
if (process.platform === 'win32') {
processArgv1 = processArgv1.replace(/\\/g, '/');
processArgv1 = `file:///${processArgv1}`;
} else {
processArgv1 = `file://${processArgv1}`;
}
return importMetaUrl === processArgv1;
};
if (isMainModule()) {
registerDependencies();
const app = createApp();
startServer(app);
}
export { registerDependencies, createApp, initializeGlobalPreferences };

View File

@ -0,0 +1,17 @@
import { MEET_ADMIN_SECRET, MEET_ADMIN_USER, MEET_PRIVATE_ACCESS, MEET_SECRET, MEET_USER } from '../environment.js';
import { injectable } from '../config/dependency-injector.config.js';
@injectable()
export class AuthService {
authenticateAdmin(username: string, password: string): boolean {
return username === MEET_ADMIN_USER && password === MEET_ADMIN_SECRET;
}
authenticateUser(username: string, password: string): boolean {
if (MEET_PRIVATE_ACCESS === 'true') {
return username === MEET_USER && password === MEET_SECRET;
}
return true;
}
}

View File

@ -0,0 +1,18 @@
export * from './auth.service.js';
export * from './logger.service.js';
export * from './livekit.service.js';
export * from './recording.service.js';
export * from './room.service.js';
export * from './participant.service.js';
export * from './s3.service.js';
export * from './livekit-webhook.service.js';
export * from './openvidu-webhook.service.js';
export * from './system-event.service.js';
export * from './task-scheduler.service.js';
export * from './mutex.service.js';
export * from './preferences/index.js';
export * from './redis.service.js';
export * from './s3.service.js';
export * from './preferences/s3-preferences-storage.js';
export * from './token.service.js';

View File

@ -0,0 +1,179 @@
import { inject, injectable } from '../config/dependency-injector.config.js';
import { EgressInfo, ParticipantInfo, Room, SendDataOptions, WebhookEvent, WebhookReceiver } from 'livekit-server-sdk';
import { RecordingHelper } from '../helpers/recording.helper.js';
import { DataTopic } from '../models/signal.model.js';
import { LiveKitService } from './livekit.service.js';
import { RecordingInfo, RecordingStatus } from '../models/recording.model.js';
import { LIVEKIT_API_KEY, LIVEKIT_API_SECRET, MEET_NAME_ID } from '../environment.js';
import { LoggerService } from './logger.service.js';
import { RoomService } from './room.service.js';
import { S3Service } from './s3.service.js';
import { RoomStatusData } from '../models/room.model.js';
import { RecordingService } from './recording.service.js';
@injectable()
export class LivekitWebhookService {
private webhookReceiver: WebhookReceiver;
constructor(
@inject(S3Service) protected s3Service: S3Service,
@inject(RecordingService) protected recordingService: RecordingService,
@inject(LiveKitService) protected livekitService: LiveKitService,
@inject(RoomService) protected roomService: RoomService,
@inject(LoggerService) protected logger: LoggerService
) {
this.webhookReceiver = new WebhookReceiver(LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
}
/**
* Retrieves a WebhookEvent from the provided request body and authentication token.
* @param body - The request body containing the webhook data.
* @param auth - The authentication token for verifying the webhook request.
* @returns The WebhookEvent extracted from the request body.
*/
async getEventFromWebhook(body: string, auth?: string): Promise<WebhookEvent> {
return await this.webhookReceiver.receive(body, auth);
}
/**
* !KNOWN ISSUE: Room metadata may be empty when track_publish and track_unpublish events are received.
* This does not affect OpenVidu Meet but is a limitation of the LiveKit server.
*
* We prioritize using the `room` object from the webhook if available.
* Otherwise, fallback to the extracted `roomName`.
*/
async webhookEventBelongsToOpenViduMeet(webhookEvent: WebhookEvent): Promise<boolean> {
// Extract relevant properties from the webhook event
const { room, egressInfo, ingressInfo } = webhookEvent;
if (room) {
// Check update room if webhook is not room_destroyed
const metadata = room.metadata ? JSON.parse(room.metadata) : {};
return metadata?.createdBy === MEET_NAME_ID;
}
// Get room from roomName
try {
// Determine the room name from available sources
const roomName = egressInfo?.roomName ?? ingressInfo?.roomName ?? '';
if (!roomName) {
this.logger.debug('Room name not found in webhook event');
return false;
}
const livekitRoom = await this.livekitService.getRoom(roomName);
if (!livekitRoom) {
this.logger.debug(`Room ${roomName} not found or no longer exists.`);
return false;
}
// Parse metadata safely, defaulting to an empty object if null/undefined
const metadata = livekitRoom.metadata ? JSON.parse(livekitRoom.metadata) : {};
return metadata?.createdBy === MEET_NAME_ID;
} catch (error) {
this.logger.error('Error checking if room was created by OpenVidu Meet:' + String(error));
return false;
}
}
async handleEgressUpdated(egressInfo: EgressInfo) {
try {
const isRecording: boolean = RecordingHelper.isRecordingEgress(egressInfo);
if (!isRecording) return;
const { roomName } = egressInfo;
let payload: RecordingInfo | undefined = undefined;
this.logger.info(`Recording egress '${egressInfo.egressId}' updated: ${egressInfo.status}`);
const topic: DataTopic = RecordingHelper.getDataTopicFromStatus(egressInfo);
payload = RecordingHelper.toRecordingInfo(egressInfo);
// Add recording metadata
const metadataPath = this.generateMetadataPath(payload);
await Promise.all([
this.s3Service.saveObject(metadataPath, payload),
this.roomService.sendSignal(roomName, payload, { topic })
]);
} catch (error) {
this.logger.warn(`Error sending data on egress updated: ${error}`);
}
}
/**
* Handles the 'egress_ended' event by gathering relevant room and recording information,
* updating the recording metadata, and sending a data payload with recording information to the room.
* @param egressInfo - Information about the ended recording egress.
*/
async handleEgressEnded(egressInfo: EgressInfo) {
try {
const isRecording: boolean = RecordingHelper.isRecordingEgress(egressInfo);
if (!isRecording) return;
const { roomName } = egressInfo;
let payload: RecordingInfo | undefined = undefined;
const topic: DataTopic = DataTopic.RECORDING_STOPPED;
payload = RecordingHelper.toRecordingInfo(egressInfo);
// Update recording metadata
const metadataPath = this.generateMetadataPath(payload);
await Promise.all([
this.s3Service.saveObject(metadataPath, payload),
this.roomService.sendSignal(roomName, payload, { topic })
]);
} catch (error) {
this.logger.warn(`Error sending data on egress ended: ${error}`);
}
}
/**
*
* Handles the 'participant_joined' event by gathering relevant room and participant information,
* checking room status, and sending a data payload with room status information to the newly joined participant.
* @param room - Information about the room where the participant joined.
* @param participant - Information about the newly joined participant.
*/
async handleParticipantJoined(room: Room, participant: ParticipantInfo) {
try {
// Do not send status signal to egress participants
if (this.livekitService.isEgressParticipant(participant)) {
return;
}
await this.sendStatusSignal(room.name, room.sid, participant.sid);
} catch (error) {
this.logger.error(`Error sending data on participant joined: ${error}`);
}
}
private async sendStatusSignal(roomName: string, roomId: string, participantSid: string) {
// Get recording list
const recordingInfo = await this.recordingService.getAllRecordingsByRoom(roomName, roomId);
// Check if recording is started in the room
const isRecordingStarted = recordingInfo.some((rec) => rec.status === RecordingStatus.STARTED);
// Construct the payload to send to the participant
const payload: RoomStatusData = {
isRecordingStarted,
recordingList: recordingInfo
};
const signalOptions: SendDataOptions = {
topic: DataTopic.ROOM_STATUS,
destinationSids: participantSid ? [participantSid] : []
};
await this.roomService.sendSignal(roomName, payload, signalOptions);
}
private generateMetadataPath(payload: RecordingInfo): string {
const metadataFilename = `${payload.roomName}-${payload.roomId}`;
const recordingFilename = payload.filename?.split('.')[0];
const egressId = payload.id;
return `.metadata/${metadataFilename}/${recordingFilename}_${egressId}.json`;
}
}

View File

@ -0,0 +1,206 @@
import { inject, injectable } from '../config/dependency-injector.config.js';
import {
AccessToken,
CreateOptions,
DataPacket_Kind,
EgressClient,
EgressInfo,
EncodedFileOutput,
ListEgressOptions,
ParticipantInfo,
Room,
RoomCompositeOptions,
RoomServiceClient,
SendDataOptions,
StreamOutput
} from 'livekit-server-sdk';
import { LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_URL, LIVEKIT_URL_PRIVATE } from '../environment.js';
import { LoggerService } from './logger.service.js';
import {
errorLivekitIsNotAvailable,
errorParticipantAlreadyExists,
errorParticipantNotFound,
errorRoomNotFound,
internalError
} from '../models/error.model.js';
import { ParticipantPermissions, ParticipantRole, TokenOptions } from '@typings-ce';
@injectable()
export class LiveKitService {
private egressClient: EgressClient;
private roomClient: RoomServiceClient;
constructor(@inject(LoggerService) protected logger: LoggerService) {
const livekitUrlHostname = LIVEKIT_URL_PRIVATE.replace(/^ws:/, 'http:').replace(/^wss:/, 'https:');
this.egressClient = new EgressClient(livekitUrlHostname, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
this.roomClient = new RoomServiceClient(livekitUrlHostname, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
}
async createRoom(options: CreateOptions): Promise<Room> {
try {
return await this.roomClient.createRoom(options);
} catch (error) {
this.logger.error('Error creating LiveKit room:', error);
throw internalError(`Error creating room: ${error}`);
}
}
async getRoom(roomName: string): Promise<Room> {
let rooms: Room[] = [];
try {
rooms = await this.roomClient.listRooms([roomName]);
} catch (error) {
this.logger.error(`Error getting room ${error}`);
throw internalError(`Error getting room: ${error}`);
}
if (rooms.length === 0) {
throw errorRoomNotFound(roomName);
}
return rooms[0];
}
async listRooms(): Promise<Room[]> {
try {
return await this.roomClient.listRooms();
} catch (error) {
this.logger.error(`Error getting LiveKit rooms ${error}`);
throw internalError(`Error getting rooms: ${error}`);
}
}
async deleteRoom(roomName: string): Promise<void> {
try {
try {
await this.getRoom(roomName);
} catch (error) {
this.logger.warn(`Livekit Room ${roomName} not found. Skipping deletion.`);
return;
}
await this.roomClient.deleteRoom(roomName);
} catch (error) {
this.logger.error(`Error deleting LiveKit room ${error}`);
throw internalError(`Error deleting room: ${error}`);
}
}
async getParticipant(roomName: string, participantName: string): Promise<ParticipantInfo> {
try {
return await this.roomClient.getParticipant(roomName, participantName);
} catch (error) {
this.logger.error(`Error getting participant ${error}`);
throw internalError(`Error getting participant: ${error}`);
}
}
async deleteParticipant(participantName: string, roomName: string): Promise<void> {
const participantExists = await this.participantExists(roomName, participantName);
if (!participantExists) {
throw errorParticipantNotFound(participantName, roomName);
}
await this.roomClient.removeParticipant(roomName, participantName);
}
async sendData(roomName: string, rawData: Record<string, any>, options: SendDataOptions): Promise<void> {
try {
if (this.roomClient) {
const data: Uint8Array = new TextEncoder().encode(JSON.stringify(rawData));
await this.roomClient.sendData(roomName, data, DataPacket_Kind.RELIABLE, options);
} else {
throw internalError(`No RoomServiceClient available`);
}
} catch (error) {
this.logger.error(`Error sending data ${error}`);
throw internalError(`Error sending data: ${error}`);
}
}
async generateToken(
options: TokenOptions,
permissions: ParticipantPermissions,
role: ParticipantRole
): Promise<string> {
const { roomName, participantName } = options;
try {
if (await this.participantExists(roomName, participantName)) {
this.logger.error(`Participant ${participantName} already exists in room ${roomName}`);
throw errorParticipantAlreadyExists(participantName, roomName);
}
} catch (error) {
this.logger.error(`Error checking participant existence, ${JSON.stringify(error)}`);
throw error;
}
this.logger.info(`Generating token for ${participantName} in room ${roomName}`);
const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, {
identity: participantName,
name: participantName,
ttl: '24h',
metadata: JSON.stringify({
livekitUrl: LIVEKIT_URL,
role,
permissions: permissions.openvidu
})
});
at.addGrant(permissions.livekit);
return at.toJwt();
}
async startRoomComposite(
roomName: string,
output: EncodedFileOutput | StreamOutput,
options: RoomCompositeOptions
): Promise<EgressInfo> {
try {
return await this.egressClient.startRoomCompositeEgress(roomName, output, options);
} catch (error: any) {
this.logger.error('Error starting Room Composite Egress');
throw internalError(`Error starting Room Composite Egress: ${JSON.stringify(error)}`);
}
}
async stopEgress(egressId: string): Promise<EgressInfo> {
try {
this.logger.info(`Stopping ${egressId} egress`);
return await this.egressClient.stopEgress(egressId);
} catch (error: any) {
this.logger.error(`Error stopping egress: JSON.stringify(error)`);
throw internalError(`Error stopping egress: ${error}`);
}
}
async getEgress(options: ListEgressOptions): Promise<EgressInfo[]> {
try {
return await this.egressClient.listEgress(options);
} catch (error: any) {
this.logger.error(`Error getting egress: ${JSON.stringify(error)}`);
throw internalError(`Error getting egress: ${error}`);
}
}
isEgressParticipant(participant: ParticipantInfo): boolean {
return participant.identity.startsWith('EG_') && participant.permission?.recorder === true;
}
private async participantExists(roomName: string, participantName: string): Promise<boolean> {
try {
const participants: ParticipantInfo[] = await this.roomClient.listParticipants(roomName);
return participants.some((participant) => participant.identity === participantName);
} catch (error: any) {
this.logger.error(error);
if (error?.cause?.code === 'ECONNREFUSED') {
throw errorLivekitIsNotAvailable();
}
return false;
}
}
}

View File

@ -0,0 +1,73 @@
import { injectable } from '../config/dependency-injector.config.js';
import winston from 'winston';
import { MEET_LOG_LEVEL } from '../environment.js';
@injectable()
export class LoggerService {
public readonly logger: winston.Logger;
constructor() {
this.logger = winston.createLogger({
level: MEET_LOG_LEVEL,
format: winston.format.combine(
winston.format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss'
}),
winston.format.printf((info) => {
return `${info.timestamp} [${info.level}] ${info.message}`;
}),
winston.format.errors({ stack: true })
// winston.format.splat(),
// winston.format.json()
)
});
if (process.env.NODE_ENV !== 'production') {
this.logger.add(
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf((info) => {
return `${info.timestamp} [${info.level}] ${info.message}`;
})
)
})
);
}
}
// Generic method to log messages with a specific level
protected log(level: string, message: string, ...meta: unknown[]): void {
this.logger.log(level, message, ...meta);
}
// Logs a message as an error
public error(message: string, ...meta: unknown[]): void {
this.log('error', message, ...meta);
}
// Logs a message as a warning
public warn(message: string, ...meta: unknown[]): void {
this.log('warn', message, ...meta);
}
// Logs a message as general information
public info(message: string, ...meta: unknown[]): void {
this.log('info', message, ...meta);
}
// Logs a message as verbose
public verbose(message: string, ...meta: unknown[]): void {
this.log('verbose', message, ...meta);
}
// Logs a message for debugging purposes
public debug(message: string, ...meta: unknown[]): void {
this.log('debug', message, ...meta);
}
// Logs a message as trivial information
public silly(message: string, ...meta: unknown[]): void {
this.log('silly', message, ...meta);
}
}

View File

@ -0,0 +1,50 @@
import Redlock, { Lock } from 'redlock';
import { RedisService } from './redis.service.js';
import { inject, injectable } from 'inversify';
import { RedisKeyPrefix } from '../models/redis.model.js';
@injectable()
export class MutexService {
protected redlockWithoutRetry: Redlock;
protected locks: Map<string, Lock>;
protected readonly TTL_MS = 10_000;
constructor(@inject(RedisService) protected redisService: RedisService) {
this.redlockWithoutRetry = this.redisService.createRedlock(0);
this.locks = new Map();
}
/**
* Acquires a lock for the specified resource.
* @param resource The resource to acquire a lock for.
* @param ttl The time-to-live (TTL) for the lock in milliseconds. Defaults to the TTL value of the MutexService.
* @returns A Promise that resolves to the acquired Lock object.
*/
async acquire(resource: string, ttl: number = this.TTL_MS): Promise<Lock | null> {
resource = RedisKeyPrefix.LOCK + resource;
try {
const lock = await this.redlockWithoutRetry.acquire([resource], ttl);
this.locks.set(resource, lock);
return lock;
} catch (error) {
return null;
}
}
/**
* Releases a lock on a resource.
*
* @param resource - The resource to release the lock on.
* @returns A Promise that resolves when the lock is released.
*/
async release(resource: string): Promise<void> {
resource = RedisKeyPrefix.LOCK + resource;
const lock = this.locks.get(resource);
if (lock) {
await lock.release();
this.locks.delete(resource);
}
}
}

View File

@ -0,0 +1,77 @@
import crypto from 'crypto';
import { inject, injectable } from '../config/dependency-injector.config.js';
import { Room } from 'livekit-server-sdk';
import { LoggerService } from './logger.service.js';
import { MEET_API_KEY, MEET_WEBHOOK_ENABLED, MEET_WEBHOOK_URL } from '../environment.js';
import { OpenViduWebhookEvent } from '../models/webhook.model.js';
@injectable()
export class OpenViduWebhookService {
constructor(@inject(LoggerService) protected logger: LoggerService) {}
async sendRoomFinishedWebhook(room: Room) {
await this.sendWebhookEvent(OpenViduWebhookEvent.ROOM_FINISHED, {
room: {
name: room.name
}
});
}
private async sendWebhookEvent(eventType: OpenViduWebhookEvent, data: object) {
if (!this.isWebhookEnabled()) return;
const payload = {
event: eventType,
...data
};
const timestamp = Date.now();
const signature = this.generateWebhookSignature(timestamp, payload);
this.logger.verbose(`Sending webhook event ${eventType}`);
try {
await this.fetchWithRetry(MEET_WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timestamp': timestamp.toString(),
'X-Signature': signature
},
body: JSON.stringify(payload)
});
} catch (error) {
this.logger.error(`Error sending webhook event ${eventType}: ${error}`);
throw error;
}
}
private generateWebhookSignature(timestamp: number, payload: object): string {
return crypto
.createHmac('sha256', MEET_API_KEY)
.update(`${timestamp}.${JSON.stringify(payload)}`)
.digest('hex');
}
private async fetchWithRetry(url: string, options: RequestInit, retries = 5, delay = 300): Promise<void> {
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
} catch (error) {
if (retries <= 0) {
throw new Error(`Request failed: ${error}`);
}
this.logger.verbose(`Retrying in ${delay / 1000} seconds... (${retries} retries left)`);
await new Promise((resolve) => setTimeout(resolve, delay));
// Retry the request after a delay with exponential backoff
return this.fetchWithRetry(url, options, retries - 1, delay * 2);
}
}
private isWebhookEnabled(): boolean {
return !!MEET_WEBHOOK_URL && MEET_WEBHOOK_ENABLED === 'true';
}
}

View File

@ -0,0 +1,133 @@
import { injectable, inject } from 'inversify';
import { LiveKitService } from './livekit.service.js';
import { LoggerService } from './logger.service.js';
import { ParticipantPermissions, ParticipantRole, TokenOptions } from '@typings-ce';
import { ParticipantInfo } from 'livekit-server-sdk';
@injectable()
export class ParticipantService {
constructor(
@inject(LoggerService) protected logger: LoggerService,
@inject(LiveKitService) protected livekitService: LiveKitService
) {}
async generateParticipantToken(role: ParticipantRole, options: TokenOptions): Promise<string> {
const permissions = this.getParticipantPermissions(role, options.roomName);
return this.livekitService.generateToken(options, permissions, role);
}
async getParticipant(roomName: string, participantName: string): Promise<ParticipantInfo | null> {
this.logger.verbose(`Fetching participant ${participantName}`);
return this.livekitService.getParticipant(roomName, participantName);
}
async participantExists(roomName: string, participantName: string): Promise<boolean> {
this.logger.verbose(`Checking if participant ${participantName} exists in room ${roomName}`);
try {
const participant = await this.getParticipant(roomName, participantName);
return participant !== null;
} catch (error) {
return false;
}
}
async deleteParticipant(participantName: string, roomName: string): Promise<void> {
this.logger.verbose(`Deleting participant ${participantName} from room ${roomName}`);
return this.livekitService.deleteParticipant(participantName, roomName);
}
getParticipantPermissions(role: ParticipantRole, roomName: string): ParticipantPermissions {
switch (role) {
case ParticipantRole.MODERATOR:
return this.generateModeratorPermissions(roomName);
case ParticipantRole.PUBLISHER:
return this.generatePublisherPermissions(roomName);
case ParticipantRole.VIEWER:
return this.generateViewerPermissions(roomName);
default:
throw new Error(`Role ${role} not supported`);
}
}
protected generateModeratorPermissions(roomName: string): ParticipantPermissions {
return {
livekit: {
roomCreate: true,
roomJoin: true,
roomList: true,
roomRecord: true,
roomAdmin: true,
room: roomName,
ingressAdmin: true,
canPublish: true,
canSubscribe: true,
canPublishData: true,
canUpdateOwnMetadata: true,
hidden: false,
recorder: true,
agent: false
},
openvidu: {
canPublishScreen: true,
canRecord: true,
canChat: true,
canChangeVirtualBackground: true
}
};
}
protected generatePublisherPermissions(roomName: string): ParticipantPermissions {
return {
livekit: {
roomJoin: true,
roomList: true,
roomRecord: false,
roomAdmin: false,
room: roomName,
ingressAdmin: false,
canPublish: true,
canSubscribe: true,
canPublishData: true,
canUpdateOwnMetadata: true,
hidden: false,
recorder: false,
agent: false
},
openvidu: {
canPublishScreen: true,
canRecord: false,
canChat: true,
canChangeVirtualBackground: true
}
};
}
protected generateViewerPermissions(roomName: string): ParticipantPermissions {
return {
livekit: {
roomJoin: true,
roomList: false,
roomRecord: false,
roomAdmin: false,
room: roomName,
ingressAdmin: false,
canPublish: false,
canSubscribe: true,
canPublishData: false,
canUpdateOwnMetadata: false,
hidden: false,
recorder: false,
agent: false
},
openvidu: {
canPublishScreen: false,
canRecord: false,
canChat: false,
canChangeVirtualBackground: false
}
};
}
}

View File

@ -0,0 +1,59 @@
import { GlobalPreferences, OpenViduMeetRoom } from '@typings-ce';
/**
* Interface for managing global preferences storage.
*/
export interface PreferencesStorage<
T extends GlobalPreferences = GlobalPreferences,
R extends OpenViduMeetRoom = OpenViduMeetRoom
> {
/**
* Initializes the storage with default preferences if they are not already set.
*
* @param defaultPreferences - The default preferences to initialize with.
* @returns A promise that resolves when the initialization is complete.
*/
initialize(defaultPreferences: T): Promise<void>;
/**
* Retrieves the global preferences of Openvidu Meet.
*
* @returns A promise that resolves to the global preferences, or null if not set.
*/
getGlobalPreferences(): Promise<T | null>;
/**
* Saves the given preferences.
*
* @param preferences - The preferences to save.
* @returns A promise that resolves to the saved preferences.
*/
saveGlobalPreferences(preferences: T): Promise<T>;
getOpenViduRooms(): Promise<R[]>;
/**
* Retrieves the {@link OpenViduMeetRoom}.
*
* @param roomName - The name of the room to retrieve.
* @returns A promise that resolves to the OpenVidu Room, or null if not found.
**/
getOpenViduRoom(roomName: string): Promise<R | null>;
/**
* Saves the OpenVidu Room.
*
* @param ovRoom - The OpenVidu Room to save.
* @returns A promise that resolves to the saved
**/
saveOpenViduRoom(ovRoom: R): Promise<R>;
/**
* Deletes the OpenVidu Room for a given room name.
*
* @param roomName - The name of the room whose should be deleted.
* @returns A promise that resolves when the room have been deleted.
**/
deleteOpenViduRoom(roomName: string): Promise<void>;
}

View File

@ -0,0 +1,31 @@
/**
* Factory class to determine and instantiate the appropriate preferences storage
* mechanism (e.g., Database or S3), based on the configuration of the application.
*/
import { PreferencesStorage } from './global-preferences-storage.interface.js';
import { S3PreferenceStorage } from './s3-preferences-storage.js';
import { MEET_PREFERENCES_STORAGE_MODE } from '../../environment.js';
import { inject, injectable } from '../../config/dependency-injector.config.js';
import { LoggerService } from '../logger.service.js';
@injectable()
export class GlobalPreferencesStorageFactory {
constructor(
@inject(S3PreferenceStorage) protected s3PreferenceStorage: S3PreferenceStorage,
@inject(LoggerService) protected logger: LoggerService
) {}
create(): PreferencesStorage {
const storageMode = MEET_PREFERENCES_STORAGE_MODE;
switch (storageMode) {
case 's3':
return this.s3PreferenceStorage;
default:
this.logger.info('No preferences storage mode specified. Defaulting to S3.');
return this.s3PreferenceStorage;
}
}
}

View File

@ -0,0 +1,156 @@
/**
* Service that provides high-level methods for managing application preferences,
* regardless of the underlying storage mechanism.
*/
import { GlobalPreferences, OpenViduMeetRoom, RoomPreferences } from '@typings-ce';
import { LoggerService } from '../logger.service.js';
import { PreferencesStorage } from './global-preferences-storage.interface.js';
import { GlobalPreferencesStorageFactory } from './global-preferences.factory.js';
import { errorRoomNotFound, OpenViduMeetError } from '../../models/error.model.js';
import { MEET_NAME_ID } from '../../environment.js';
import { injectable, inject } from '../../config/dependency-injector.config.js';
@injectable()
export class GlobalPreferencesService<
G extends GlobalPreferences = GlobalPreferences,
R extends OpenViduMeetRoom = OpenViduMeetRoom
> {
protected storage: PreferencesStorage;
constructor(
@inject(LoggerService) protected logger: LoggerService,
@inject(GlobalPreferencesStorageFactory) protected storageFactory: GlobalPreferencesStorageFactory
) {
this.storage = this.storageFactory.create();
}
/**
* Initializes default preferences if not already initialized.
* @returns {Promise<G>} Default global preferences.
*/
async ensurePreferencesInitialized(): Promise<G> {
const preferences = this.getDefaultPreferences();
try {
await this.storage.initialize(preferences);
return preferences as G;
} catch (error) {
this.handleError(error, 'Error initializing default preferences');
return Promise.resolve({} as G);
}
}
/**
* Retrieves the global preferences, initializing them if necessary.
* @returns {Promise<GlobalPreferences>}
*/
async getGlobalPreferences(): Promise<G> {
const preferences = await this.storage.getGlobalPreferences();
if (preferences) return preferences as G;
return await this.ensurePreferencesInitialized();
}
async saveOpenViduRoom(ovRoom: R): Promise<R> {
this.logger.info(`Saving OpenVidu room ${ovRoom.roomName}`);
return this.storage.saveOpenViduRoom(ovRoom) as Promise<R>;
}
async getOpenViduRooms(): Promise<R[]> {
return this.storage.getOpenViduRooms() as Promise<R[]>;
}
/**
* Retrieves the preferences associated with a specific room.
*
* @param roomName - The unique identifier for the room.
* @returns A promise that resolves to the room's preferences.
* @throws Error if the room preferences are not found.
*/
async getOpenViduRoom(roomName: string): Promise<R> {
const openviduRoom = await this.storage.getOpenViduRoom(roomName);
if (!openviduRoom) {
this.logger.error(`Room not found for room ${roomName}`);
throw errorRoomNotFound(roomName);
}
return openviduRoom as R;
}
async deleteOpenViduRoom(roomName: string): Promise<void> {
return this.storage.deleteOpenViduRoom(roomName);
}
async getOpenViduRoomPreferences(roomName: string): Promise<RoomPreferences> {
const openviduRoom = await this.getOpenViduRoom(roomName);
if (!openviduRoom.preferences) {
throw new Error('Room preferences not found');
}
return openviduRoom.preferences;
}
/**
* Updates room preferences in storage.
* @param {RoomPreferences} roomPreferences
* @returns {Promise<GlobalPreferences>}
*/
async updateOpenViduRoomPreferences(roomName: string, roomPreferences: RoomPreferences): Promise<R> {
// TODO: Move validation to the controller layer
this.validateRoomPreferences(roomPreferences);
const openviduRoom = await this.getOpenViduRoom(roomName);
openviduRoom.preferences = roomPreferences;
return this.saveOpenViduRoom(openviduRoom);
}
/**
* Validates the room preferences.
* @param {RoomPreferences} preferences
*/
validateRoomPreferences(preferences: RoomPreferences) {
const { recordingPreferences, chatPreferences, virtualBackgroundPreferences } = preferences;
if (!recordingPreferences || !chatPreferences || !virtualBackgroundPreferences) {
throw new Error('All room preferences must be provided');
}
if (typeof preferences.recordingPreferences.enabled !== 'boolean') {
throw new Error('Invalid value for recordingPreferences.enabled');
}
if (typeof preferences.chatPreferences.enabled !== 'boolean') {
throw new Error('Invalid value for chatPreferences.enabled');
}
if (typeof preferences.virtualBackgroundPreferences.enabled !== 'boolean') {
throw new Error('Invalid value for virtualBackgroundPreferences.enabled');
}
}
/**
* Returns the default global preferences.
* @returns {G}
*/
protected getDefaultPreferences(): G {
return {
projectId: MEET_NAME_ID
} as G;
}
/**
* Handles errors and logs them.
* @param {any} error
* @param {string} message
*/
protected handleError(error: any, message: string) {
if (error instanceof OpenViduMeetError) {
this.logger.error(`${message}: ${error.message}`);
} else {
this.logger.error(`${message}: Unexpected error`);
}
}
}

View File

@ -0,0 +1,3 @@
export * from './global-preferences.service.js';
export * from './global-preferences-storage.interface.js';
export * from './global-preferences.factory.js';

View File

@ -0,0 +1,212 @@
/**
* Implements storage for preferences using S3.
* This is used when the application is configured to operate in "s3" mode.
*/
import { GlobalPreferences, OpenViduMeetRoom } from '@typings-ce';
import { PreferencesStorage } from './global-preferences-storage.interface.js';
import { S3Service } from '../s3.service.js';
import { LoggerService } from '../logger.service.js';
import { RedisService } from '../redis.service.js';
import { OpenViduMeetError } from '../../models/error.model.js';
import { inject, injectable } from '../../config/dependency-injector.config.js';
@injectable()
export class S3PreferenceStorage<
G extends GlobalPreferences = GlobalPreferences,
R extends OpenViduMeetRoom = OpenViduMeetRoom
> implements PreferencesStorage
{
protected readonly PREFERENCES_PATH = '.openvidu-meet';
protected readonly GLOBAL_PREFERENCES_KEY = 'openvidu-meet-preferences';
constructor(
@inject(LoggerService) protected logger: LoggerService,
@inject(S3Service) protected s3Service: S3Service,
@inject(RedisService) protected redisService: RedisService
) {}
async initialize(defaultPreferences: G): Promise<void> {
const existingPreferences = await this.getGlobalPreferences();
if (existingPreferences) {
if (existingPreferences.projectId !== defaultPreferences.projectId) {
this.logger.warn(
`Existing preferences are associated with a different project (Project ID: ${existingPreferences.projectId}). Replacing them with the default preferences for the current project.`
);
await this.saveGlobalPreferences(defaultPreferences);
}
} else {
this.logger.info('Saving default preferences to S3');
await this.saveGlobalPreferences(defaultPreferences);
}
}
async getGlobalPreferences(): Promise<G | null> {
try {
let preferences: G | null = await this.getFromRedis<G>(this.GLOBAL_PREFERENCES_KEY);
if (!preferences) {
// Fallback to fetching from S3 if Redis doesn't have it
this.logger.debug('Preferences not found in Redis. Fetching from S3...');
preferences = await this.getFromS3<G>(`${this.PREFERENCES_PATH}/${this.GLOBAL_PREFERENCES_KEY}.json`);
if (preferences) {
await this.redisService.set(this.GLOBAL_PREFERENCES_KEY, JSON.stringify(preferences), false);
}
}
return preferences;
} catch (error) {
this.handleError(error, 'Error fetching preferences');
return null;
}
}
async saveGlobalPreferences(preferences: G): Promise<G> {
try {
await Promise.all([
this.s3Service.saveObject(`${this.PREFERENCES_PATH}/${this.GLOBAL_PREFERENCES_KEY}.json`, preferences),
this.redisService.set(this.GLOBAL_PREFERENCES_KEY, JSON.stringify(preferences), false)
]);
return preferences;
} catch (error) {
this.handleError(error, 'Error saving preferences');
throw error;
}
}
async saveOpenViduRoom(ovRoom: R): Promise<R> {
const { roomName } = ovRoom;
try {
await Promise.all([
this.s3Service.saveObject(`${this.PREFERENCES_PATH}/${roomName}/${roomName}.json`, ovRoom),
//TODO: Implement ttl for room preferences
this.redisService.set(roomName, JSON.stringify(ovRoom), false)
]);
return ovRoom;
} catch (error) {
this.handleError(error, `Error saving Room preferences for room ${roomName}`);
throw error;
}
}
async getOpenViduRooms(): Promise<R[]> {
try {
const content = await this.s3Service.listObjects(this.PREFERENCES_PATH);
const roomFiles =
content.Contents?.filter(
(file) =>
file?.Key?.endsWith('.json') &&
file.Key !== `${this.PREFERENCES_PATH}/${this.GLOBAL_PREFERENCES_KEY}.json`
) ?? [];
if (roomFiles.length === 0) {
this.logger.verbose('No OpenVidu rooms found in S3');
return [];
}
// Extract room names from file paths
const roomNamesList = roomFiles.map((file) => this.extractRoomName(file.Key)).filter(Boolean) as string[];
// Fetch room preferences in parallel
const rooms = await Promise.all(
roomNamesList.map(async (roomName: string) => {
if (!roomName) return null;
try {
return await this.getOpenViduRoom(roomName);
} catch (error: any) {
this.logger.warn(`Failed to fetch room "${roomName}": ${error.message}`);
return null;
}
})
);
// Filter out null values
return rooms.filter(Boolean) as R[];
} catch (error) {
this.handleError(error, 'Error fetching Room preferences');
return [];
}
}
/**
* Extracts the room name from the given file path.
* Assumes the room name is located one directory before the file name.
* Example: 'path/to/roomName/file.json' -> 'roomName'
* @param filePath - The S3 object key representing the file path.
* @returns The extracted room name or null if extraction fails.
*/
private extractRoomName(filePath?: string): string | null {
if (!filePath) return null;
const parts = filePath.split('/');
if (parts.length < 2) {
this.logger.warn(`Invalid room file path: ${filePath}`);
return null;
}
return parts[parts.length - 2];
}
async getOpenViduRoom(roomName: string): Promise<R | null> {
try {
const room: R | null = await this.getFromRedis<R>(roomName);
if (!room) {
this.logger.debug(`Room preferences not found in Redis. Fetching from S3...`);
return await this.getFromS3<R>(`${this.PREFERENCES_PATH}/${roomName}/${roomName}.json`);
}
return room;
} catch (error) {
this.handleError(error, `Error fetching Room preferences for room ${roomName}`);
return null;
}
}
async deleteOpenViduRoom(roomName: string): Promise<void> {
try {
await Promise.all([
this.s3Service.deleteObject(`${this.PREFERENCES_PATH}/${roomName}/${roomName}.json`),
this.redisService.delete(roomName)
]);
} catch (error) {
this.handleError(error, `Error deleting Room preferences for room ${roomName}`);
}
}
protected async getFromRedis<U>(key: string): Promise<U | null> {
let response: string | null = null;
response = await this.redisService.get(key);
if (response) {
this.logger.debug(`Object ${key} found in Redis`);
return JSON.parse(response) as U;
}
return null;
}
protected async getFromS3<U>(path: string): Promise<U | null> {
const response = await this.s3Service.getObjectAsJson(path);
if (response) {
this.logger.verbose(`Object found in S3 at path: ${path}`);
return response as U;
}
return null;
}
protected handleError(error: any, message: string) {
if (error instanceof OpenViduMeetError) {
this.logger.error(`${message}: ${error.message}`);
} else {
this.logger.error(`${message}: Unexpected error`);
}
}
}

View File

@ -0,0 +1,272 @@
import {
EncodedFileOutput,
EncodedFileType,
ListEgressOptions,
RoomCompositeOptions,
SendDataOptions
} from 'livekit-server-sdk';
import { Readable } from 'stream';
import { LiveKitService } from './livekit.service.js';
import {
OpenViduMeetError,
errorRecordingAlreadyStarted,
errorRecordingNotFound,
errorRecordingNotStopped,
errorRoomNotFound,
internalError
} from '../models/error.model.js';
import { S3Service } from './s3.service.js';
import { DataTopic } from '../models/signal.model.js';
import { LoggerService } from './logger.service.js';
import { RecordingInfo, RecordingStatus } from '../models/recording.model.js';
import { RecordingHelper } from '../helpers/recording.helper.js';
import { MEET_S3_BUCKET } from '../environment.js';
import { RoomService } from './room.service.js';
import { inject, injectable } from '../config/dependency-injector.config.js';
@injectable()
export class RecordingService {
constructor(
@inject(S3Service) protected s3Service: S3Service,
@inject(LiveKitService) protected livekitService: LiveKitService,
@inject(RoomService) protected roomService: RoomService,
@inject(LoggerService) protected logger: LoggerService
) {}
async startRecording(roomName: string): Promise<RecordingInfo> {
try {
const egressOptions: ListEgressOptions = {
roomName,
active: true
};
const [activeEgressResult, roomResult] = await Promise.allSettled([
this.livekitService.getEgress(egressOptions),
this.livekitService.getRoom(roomName)
]);
// Get the results of the promises
const activeEgress = activeEgressResult.status === 'fulfilled' ? activeEgressResult.value : null;
const room = roomResult.status === 'fulfilled' ? roomResult.value : null;
// If there is an active egress, it means that the recording is already started
if (!activeEgress || activeEgressResult.status === 'rejected') {
throw errorRecordingAlreadyStarted(roomName);
}
if (!room) {
throw errorRoomNotFound(roomName);
}
const recordingId = `${roomName}-${room.sid || Date.now()}`;
const options = this.generateCompositeOptionsFromRequest();
const output = this.generateFileOutputFromRequest(recordingId);
const egressInfo = await this.livekitService.startRoomComposite(roomName, output, options);
return RecordingHelper.toRecordingInfo(egressInfo);
} catch (error) {
this.logger.error(`Error starting recording in room ${roomName}: ${error}`);
let payload = { error: error, statusCode: 500 };
const options: SendDataOptions = {
destinationSids: [],
topic: DataTopic.RECORDING_FAILED
};
if (error instanceof OpenViduMeetError) {
payload = { error: error.message, statusCode: error.statusCode };
}
this.roomService.sendSignal(roomName, payload, options);
throw error;
}
}
async stopRecording(egressId: string): Promise<RecordingInfo> {
try {
const options: ListEgressOptions = {
egressId,
active: true
};
const egressArray = await this.livekitService.getEgress(options);
if (egressArray.length === 0) {
throw errorRecordingNotFound(egressId);
}
const egressInfo = await this.livekitService.stopEgress(egressId);
return RecordingHelper.toRecordingInfo(egressInfo);
} catch (error) {
this.logger.error(`Error stopping recording ${egressId}: ${error}`);
throw error;
}
}
async deleteRecording(egressId: string, isRequestedByAdmin: boolean): Promise<RecordingInfo> {
try {
// Get the recording object from the S3 bucket
const metadataObject = await this.s3Service.listObjects('.metadata', `.*${egressId}.*.json`);
if (!metadataObject.Contents || metadataObject.Contents.length === 0) {
throw errorRecordingNotFound(egressId);
}
const metadataPath = metadataObject.Contents[0].Key;
const recordingInfo = (await this.s3Service.getObjectAsJson(metadataPath!)) as RecordingInfo;
if (recordingInfo.status === RecordingStatus.STARTED) {
throw errorRecordingNotStopped(egressId);
}
const recordingPath = RecordingHelper.extractFilename(recordingInfo);
if (!recordingPath) throw internalError(`Error extracting path from recording ${egressId}`);
this.logger.info(`Deleting recording from S3 ${recordingPath}`);
await Promise.all([this.s3Service.deleteObject(metadataPath!), this.s3Service.deleteObject(recordingPath)]);
if (!isRequestedByAdmin) {
const signalOptions: SendDataOptions = {
destinationSids: [],
topic: DataTopic.RECORDING_DELETED
};
await this.roomService.sendSignal(recordingInfo.roomName, recordingInfo, signalOptions);
}
return recordingInfo;
} catch (error) {
this.logger.error(`Error deleting recording ${egressId}: ${error}`);
throw error;
}
}
/**
* Retrieves the list of all recordings.
* @returns A promise that resolves to an array of RecordingInfo objects.
*/
async getAllRecordings(): Promise<{ recordingInfo: RecordingInfo[]; continuationToken?: string }> {
try {
const allEgress = await this.s3Service.listObjects('.metadata', '.json');
const promises: Promise<RecordingInfo>[] = [];
allEgress.Contents?.forEach((item) => {
if (item?.Key?.includes('.json')) {
promises.push(this.s3Service.getObjectAsJson(item.Key) as Promise<RecordingInfo>);
}
});
return { recordingInfo: await Promise.all(promises), continuationToken: undefined };
} catch (error) {
this.logger.error(`Error getting recordings: ${error}`);
throw error;
}
}
/**
* Retrieves all recordings for a given room.
*
* @param roomName - The name of the room.
* @param roomId - The ID of the room.
* @returns A promise that resolves to an array of RecordingInfo objects.
* @throws If there is an error retrieving the recordings.
*/
async getAllRecordingsByRoom(roomName: string, roomId: string): Promise<RecordingInfo[]> {
try {
// Get all recordings that match the room name and room ID from the S3 bucket
const roomNameSanitized = this.sanitizeRegExp(roomName);
const roomIdSanitized = this.sanitizeRegExp(roomId);
// Match the room name and room ID in any order
const regexPattern = `${roomNameSanitized}.*${roomIdSanitized}|${roomIdSanitized}.*${roomNameSanitized}\\.json`;
const metadatagObject = await this.s3Service.listObjects('.metadata', regexPattern);
if (!metadatagObject.Contents || metadatagObject.Contents.length === 0) {
this.logger.verbose(`No recordings found for room ${roomName}. Returning an empty array.`);
return [];
}
const promises: Promise<RecordingInfo>[] = [];
metadatagObject.Contents?.forEach((item) => {
promises.push(this.s3Service.getObjectAsJson(item.Key!) as Promise<RecordingInfo>);
});
return Promise.all(promises);
} catch (error) {
this.logger.error(`Error getting recordings: ${error}`);
throw error;
}
}
private async getRecording(egressId: string): Promise<RecordingInfo> {
const egressIdSanitized = this.sanitizeRegExp(egressId);
const regexPattern = `.*${egressIdSanitized}.*\\.json`;
const metadataObject = await this.s3Service.listObjects('.metadata', regexPattern);
if (!metadataObject.Contents || metadataObject.Contents.length === 0) {
throw errorRecordingNotFound(egressId);
}
const recording = (await this.s3Service.getObjectAsJson(metadataObject.Contents[0].Key!)) as RecordingInfo;
return recording;
// return RecordingHelper.toRecordingInfo(recording);
}
async getRecordingAsStream(
recordingId: string,
range?: string
): Promise<{ fileSize: number | undefined; fileStream: Readable; start?: number; end?: number }> {
const RECORDING_FILE_PORTION_SIZE = 5 * 1024 * 1024; // 5MB
const recordingInfo: RecordingInfo = await this.getRecording(recordingId);
const recordingPath = RecordingHelper.extractFilename(recordingInfo);
if (!recordingPath) throw new Error(`Error extracting path from recording ${recordingId}`);
const data = await this.s3Service.getHeaderObject(recordingPath);
const fileSize = data.ContentLength;
if (range && fileSize) {
// Parse the range header
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const endRange = parts[1] ? parseInt(parts[1], 10) : start + RECORDING_FILE_PORTION_SIZE;
const end = Math.min(endRange, fileSize - 1);
const fileStream = await this.s3Service.getObjectAsStream(recordingPath, MEET_S3_BUCKET, {
start,
end
});
return { fileSize, fileStream, start, end };
} else {
const fileStream = await this.s3Service.getObjectAsStream(recordingPath);
return { fileSize, fileStream };
}
}
private generateCompositeOptionsFromRequest(layout = 'speaker'): RoomCompositeOptions {
return {
layout: layout
// customBaseUrl: customLayout,
// audioOnly: false,
// videoOnly: false
};
}
/**
* Generates a file output object based on the provided room name and file name.
* @param recordingId - The recording id.
* @param fileName - The name of the file (default is 'recording').
* @returns The generated file output object.
*/
private generateFileOutputFromRequest(recordingId: string): EncodedFileOutput {
// Added unique identifier to the file path for avoiding overwriting
const filepath = `${recordingId}/${recordingId}-${Date.now()}`;
return new EncodedFileOutput({
fileType: EncodedFileType.DEFAULT_FILETYPE,
filepath,
disableManifest: true
});
}
private sanitizeRegExp(str: string) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
}

View File

@ -0,0 +1,231 @@
import { injectable } from '../config/dependency-injector.config.js';
import * as config from '../environment.js';
import { Redis, RedisOptions, SentinelAddress } from 'ioredis';
import {
REDIS_DB,
REDIS_HOST,
REDIS_PASSWORD,
REDIS_PORT,
REDIS_SENTINEL_MASTER_NAME,
REDIS_SENTINEL_HOST_LIST,
REDIS_SENTINEL_PASSWORD,
REDIS_USERNAME
} from '../environment.js';
import { internalError } from '../models/error.model.js';
import { LoggerService } from './logger.service.js';
import { EventEmitter } from 'events';
import Redlock from 'redlock';
@injectable()
export class RedisService extends LoggerService {
protected readonly DEFAULT_TTL: number = 32 * 60 * 60 * 24; // 32 days
protected redis: Redis;
protected isConnected = false;
public events: EventEmitter;
constructor() {
super();
this.events = new EventEmitter();
const redisOptions = this.loadRedisConfig();
this.redis = new Redis(redisOptions);
this.redis.on('connect', () => {
if (!this.isConnected) {
this.logger.verbose('Connected to Redis');
} else {
this.logger.verbose('Reconnected to Redis');
}
this.isConnected = true;
this.events.emit('redisConnected');
});
this.redis.on('error', (e) => this.logger.error('Error Redis', e));
this.redis.on('end', () => {
this.isConnected = false;
this.logger.warn('Redis disconnected');
});
}
createRedlock(retryCount = -1, retryDelay = 200) {
return new Redlock([this.redis], {
driftFactor: 0.01,
retryCount,
retryDelay,
retryJitter: 200 // Random variation in the time between retries.
});
}
public onReady(callback: () => void) {
if (this.isConnected) {
callback();
}
this.events.on('redisConnected', callback);
}
/**
* Retrieves all keys from Redis that match the specified pattern.
*
* @param pattern - The pattern to match against Redis keys.
* @returns A promise that resolves to an array of matching keys.
* @throws {internalRecordingError} If there is an error retrieving keys from Redis.
*/
async getKeys(pattern: string): Promise<string[]> {
let cursor = '0';
const keys: Set<string> = new Set();
do {
const [nextCursor, partialKeys] = await this.redis.scan(cursor, 'MATCH', pattern);
partialKeys.forEach((key) => keys.add(key));
cursor = nextCursor;
} while (cursor !== '0');
return Array.from(keys);
}
/**
* Checks if a given key exists in the Redis store.
*
* @param {string} key - The key to check for existence.
* @returns {Promise<boolean>} - A promise that resolves to `true` if the key exists, otherwise `false`.
*/
async exists(key: string): Promise<boolean> {
const result = await this.get(key);
return !!result;
}
get(key: string, hashKey?: string): Promise<string | null> {
try {
if (hashKey) {
return this.redis.hget(key, hashKey);
} else {
return this.redis.get(key);
}
} catch (error) {
this.logger.error('Error getting value from Redis', error);
throw internalError(error);
}
}
// getAll(key: string): Promise<Record<string, string>> {
// try {
// return this.redis.hgetall(key);
// } catch (error) {
// this.logger.error('Error getting value from Redis', error);
// throw internalError(error);
// }
// }
// getDel(key: string): Promise<string | null> {
// try {
// return this.redis.getdel(key);
// } catch (error) {
// this.logger.error('Error getting and deleting value from Redis', error);
// throw internalError(error);
// }
// }
/**
* Sets a value in Redis with an optional TTL (time-to-live).
*
* @param {string} key - The key under which the value will be stored.
* @param {any} value - The value to be stored. Can be a string, number, boolean, or object.
* @param {boolean} [withTTL=true] - Whether to set a TTL for the key. Defaults to true.
* @returns {Promise<string>} - A promise that resolves to 'OK' if the operation is successful.
* @throws {Error} - Throws an error if the value type is invalid or if there is an issue setting the value in Redis.
*/
async set(key: string, value: any, withTTL = true): Promise<string> {
try {
const valueType = typeof value;
if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') {
if (withTTL) {
await this.redis.set(key, value, 'EX', this.DEFAULT_TTL);
} else {
await this.redis.set(key, value);
}
} else if (valueType === 'object') {
await this.redis.hmset(key, value);
if (withTTL) await this.redis.expire(key, this.DEFAULT_TTL);
} else {
throw new Error('Invalid value type');
}
return 'OK';
} catch (error) {
this.logger.error('Error setting value in Redis', error);
throw error;
}
}
/**
* Deletes a key from Redis.
* @param key - The key to delete.
* @param hashKey - The hash key to delete. If provided, it will delete the hash key from the hash stored at the given key.
* @returns A promise that resolves to the number of keys deleted.
*/
delete(key: string, hashKey?: string): Promise<number> {
try {
if (hashKey) {
return this.redis.hdel(key, hashKey);
} else {
return this.redis.del(key);
}
} catch (error) {
throw internalError(`Error deleting key from Redis ${error}`);
}
}
quit() {
this.redis.quit();
}
async checkHealth() {
return (await this.redis.ping()) === 'PONG';
}
private loadRedisConfig(): RedisOptions {
// Check if openviduCall module is enabled. If not, exit the process
config.checkModuleEnabled();
//Check if Redis Sentinel is configured
if (REDIS_SENTINEL_HOST_LIST) {
const sentinels: Array<SentinelAddress> = [];
const sentinelHosts = REDIS_SENTINEL_HOST_LIST.split(',');
sentinelHosts.forEach((host) => {
const rawHost = host.split(':');
if (rawHost.length !== 2) {
throw new Error('The Redis Sentinel host list is required');
}
const hostName = rawHost[0];
const port = parseInt(rawHost[1]);
sentinels.push({ host: hostName, port });
});
if (!REDIS_SENTINEL_PASSWORD) throw new Error('The Redis Sentinel password is required');
this.logger.verbose('Using Redis Sentinel');
return {
sentinels,
sentinelPassword: REDIS_SENTINEL_PASSWORD,
username: REDIS_USERNAME,
password: REDIS_PASSWORD,
name: REDIS_SENTINEL_MASTER_NAME,
db: Number(REDIS_DB)
};
} else {
this.logger.verbose('Using Redis standalone');
return {
port: Number(REDIS_PORT),
host: REDIS_HOST,
username: REDIS_USERNAME,
password: REDIS_PASSWORD,
db: Number(REDIS_DB)
};
}
}
}

View File

@ -0,0 +1,358 @@
import { uid as secureUid } from 'uid/secure';
import { inject, injectable } from '../config/dependency-injector.config.js';
import { CreateOptions, Room, SendDataOptions } from 'livekit-server-sdk';
import { LoggerService } from './logger.service.js';
import { LiveKitService } from './livekit.service.js';
import { GlobalPreferencesService } from './preferences/global-preferences.service.js';
import { OpenViduMeetRoom, OpenViduMeetRoomOptions, ParticipantRole } from '@typings-ce';
import { OpenViduRoomHelper } from '../helpers/room.helper.js';
import { SystemEventService } from './system-event.service.js';
import { TaskSchedulerService } from './task-scheduler.service.js';
import { ParticipantService } from './participant.service.js';
/**
* Service for managing OpenVidu Meet rooms.
*
* This service provides methods to create, list, retrieve, delete, and send signals to OpenVidu rooms.
* It uses the LiveKitService to interact with the underlying LiveKit rooms.
*/
@injectable()
export class RoomService {
constructor(
@inject(LoggerService) protected logger: LoggerService,
@inject(GlobalPreferencesService) protected globalPrefService: GlobalPreferencesService,
@inject(LiveKitService) protected livekitService: LiveKitService,
@inject(ParticipantService) protected participantService: ParticipantService,
@inject(SystemEventService) protected systemEventService: SystemEventService,
@inject(TaskSchedulerService) protected taskSchedulerService: TaskSchedulerService
) {}
/**
* Initializes the room service.
*
* This method sets up the room garbage collector and event listeners.
*/
async initialize(): Promise<void> {
this.systemEventService.onRedisReady(async () => {
try {
await this.deleteOpenViduExpiredRooms();
} catch (error) {
this.logger.error('Error deleting OpenVidu expired rooms:', error);
}
await Promise.all([
this.restoreMissingLivekitRooms().catch((error) =>
this.logger.error('Error restoring missing rooms:', error)
),
this.taskSchedulerService.startRoomGarbageCollector(this.deleteExpiredRooms.bind(this))
]);
});
}
/**
* Creates an OpenVidu room with the specified options.
*
* @param {string} baseUrl - The base URL for the room.
* @param {OpenViduMeetRoomOptions} options - The options for creating the OpenVidu room.
* @returns {Promise<OpenViduMeetRoom>} A promise that resolves to the created OpenVidu room.
*
* @throws {Error} If the room creation fails.
*
*/
async createRoom(baseUrl: string, roomOptions: OpenViduMeetRoomOptions): Promise<OpenViduMeetRoom> {
const livekitRoom: Room = await this.createLivekitRoom(roomOptions);
const openviduRoom: OpenViduMeetRoom = this.generateOpenViduRoom(baseUrl, livekitRoom, roomOptions);
await this.globalPrefService.saveOpenViduRoom(openviduRoom);
return openviduRoom;
}
/**
* Retrieves a list of rooms.
* @returns A Promise that resolves to an array of {@link OpenViduMeetRoom} objects.
* @throws If there was an error retrieving the rooms.
*/
async listOpenViduRooms(): Promise<OpenViduMeetRoom[]> {
return await this.globalPrefService.getOpenViduRooms();
}
/**
* Retrieves an OpenVidu room by its name.
*
* @param roomName - The name of the room to retrieve.
* @returns A promise that resolves to an {@link OpenViduMeetRoom} object.
*/
async getOpenViduRoom(roomName: string): Promise<OpenViduMeetRoom> {
return await this.globalPrefService.getOpenViduRoom(roomName);
}
/**
* Deletes OpenVidu and LiveKit rooms.
*
* This method deletes rooms from both LiveKit and OpenVidu services.
*
* @param roomNames - An array of room names to be deleted.
* @returns A promise that resolves with an array of successfully deleted room names.
*/
async deleteRooms(roomNames: string[]): Promise<string[]> {
const [openViduResults, livekitResults] = await Promise.all([
this.deleteOpenViduRooms(roomNames),
Promise.allSettled(roomNames.map((roomName) => this.livekitService.deleteRoom(roomName)))
]);
// Log errors from LiveKit deletions
livekitResults.forEach((result, index) => {
if (result.status === 'rejected') {
this.logger.error(`Failed to delete LiveKit room "${roomNames[index]}": ${result.reason}`);
}
});
// Combine successful deletions
const successfullyDeleted = new Set(openViduResults);
livekitResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
successfullyDeleted.add(roomNames[index]);
}
});
return Array.from(successfullyDeleted);
}
/**
* Deletes OpenVidu rooms.
*
* @param roomNames - List of room names to delete.
* @returns A promise that resolves with an array of successfully deleted room names.
*/
async deleteOpenViduRooms(roomNames: string[]): Promise<string[]> {
const results = await Promise.allSettled(
roomNames.map((roomName) => this.globalPrefService.deleteOpenViduRoom(roomName))
);
const successfulRooms: string[] = [];
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successfulRooms.push(roomNames[index]);
} else {
this.logger.error(`Failed to delete OpenVidu room "${roomNames[index]}": ${result.reason}`);
}
});
if (successfulRooms.length === roomNames.length) {
this.logger.verbose('All OpenVidu rooms have been deleted.');
}
return successfulRooms;
}
/**
* Determines the role of a participant in a room based on the provided secret.
*
* @param room - The OpenVidu room object.
* @param secret - The secret used to identify the participant's role.
* @returns The role of the participant {@link ParticipantRole}.
* @throws Will throw an error if the secret is invalid.
*/
async getRoomSecretRole(roomName: string, secret: string): Promise<ParticipantRole> {
const room = await this.getOpenViduRoom(roomName);
if (room.moderatorRoomUrl.includes(secret)) {
return ParticipantRole.MODERATOR;
}
if (room.publisherRoomUrl.includes(secret)) {
return ParticipantRole.PUBLISHER;
}
if (room.viewerRoomUrl.includes(secret)) {
return ParticipantRole.VIEWER;
}
throw new Error('Invalid secret');
}
/**
* Sends a signal to participants in a specified room.
*
* @param roomName - The name of the room where the signal will be sent.
* @param rawData - The raw data to be sent as the signal.
* @param options - Options for sending the data, including the topic and destination identities.
* @returns A promise that resolves when the signal has been sent.
*/
async sendSignal(roomName: string, rawData: any, options: SendDataOptions): Promise<void> {
this.logger.verbose(
`Sending signal "${options.topic}" to ${
options.destinationIdentities ? `participant(s) ${options.destinationIdentities}` : 'all participants'
} in room "${roomName}".`
);
this.livekitService.sendData(roomName, rawData, options);
}
/**
* Creates a Livekit room with the specified options.
*
* @param roomOptions - The options for creating the room.
* @returns A promise that resolves to the created room.
*/
protected async createLivekitRoom(roomOptions: OpenViduMeetRoomOptions): Promise<Room> {
const livekitRoomOptions: CreateOptions = OpenViduRoomHelper.generateLivekitRoomOptions(roomOptions);
return this.livekitService.createRoom(livekitRoomOptions);
}
/**
* Converts a LiveKit room to an OpenVidu room.
*
* @param livekitRoom - The LiveKit room object containing metadata, name, and creation time.
* @param roomOptions - Options for the OpenVidu room including preferences and end date.
* @returns The converted OpenVidu room object.
* @throws Will throw an error if metadata is not found in the LiveKit room.
*/
protected generateOpenViduRoom(
baseUrl: string,
livekitRoom: Room,
roomOptions: OpenViduMeetRoomOptions
): OpenViduMeetRoom {
const { name: roomName, creationTime } = livekitRoom;
const { preferences, expirationDate, roomNamePrefix, maxParticipants } = roomOptions;
const openviduRoom: OpenViduMeetRoom = {
roomName,
roomNamePrefix,
creationDate: Number(creationTime) * 1000,
maxParticipants,
expirationDate,
moderatorRoomUrl: `${baseUrl}/${roomName}/?secret=${secureUid(10)}`,
publisherRoomUrl: `${baseUrl}/${roomName}?secret=${secureUid(10)}`,
viewerRoomUrl: `${baseUrl}/${roomName}/?secret=${secureUid(10)}`,
preferences
};
return openviduRoom;
}
/**
* Deletes OpenVidu expired rooms and consequently LiveKit rooms.
*
* This method delete the rooms that have an expiration date earlier than the current time.
*
* @returns {Promise<void>} A promise that resolves when the deletion process is complete.
**/
protected async deleteExpiredRooms(): Promise<void> {
try {
const ovExpiredRooms = await this.deleteOpenViduExpiredRooms();
if (ovExpiredRooms.length === 0) {
this.logger.verbose('No expired rooms found to delete.');
return;
}
const livekitResults = await Promise.allSettled(
ovExpiredRooms.map((roomName) => this.livekitService.deleteRoom(roomName))
);
const successfulRooms: string[] = [];
livekitResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
successfulRooms.push(ovExpiredRooms[index]);
} else {
this.logger.error(`Failed to delete OpenVidu room "${ovExpiredRooms[index]}": ${result.reason}`);
}
});
this.logger.verbose(
`Successfully deleted ${successfulRooms.length} expired rooms: ${successfulRooms.join(', ')}`
);
} catch (error) {
this.logger.error('Error deleting expired rooms:', error);
}
}
/**
* Deletes expired OpenVidu rooms.
*
* This method checks for rooms that have an expiration date earlier than the current time
* and attempts to delete them.
*
* @returns {Promise<void>} A promise that resolves when the operation is complete.
*/
protected async deleteOpenViduExpiredRooms(): Promise<string[]> {
const now = Date.now();
this.logger.verbose(`Checking OpenVidu expired rooms at ${new Date(now).toISOString()}`);
const rooms = await this.listOpenViduRooms();
const expiredRooms = rooms
.filter((room) => room.expirationDate && room.expirationDate < now)
.map((room) => room.roomName);
if (expiredRooms.length === 0) {
this.logger.verbose('No OpenVidu expired rooms to delete.');
return [];
}
this.logger.info(`Deleting ${expiredRooms.length} OpenVidu expired rooms: ${expiredRooms.join(', ')}`);
return await this.deleteOpenViduRooms(expiredRooms);
}
/**
* Restores missing Livekit rooms by comparing the list of rooms from Livekit and OpenVidu.
* If any rooms are missing in Livekit, they will be created.
*
* @returns {Promise<void>} A promise that resolves when the restoration process is complete.
*
* @protected
*/
protected async restoreMissingLivekitRooms(): Promise<void> {
this.logger.verbose(`Checking missing Livekit rooms ...`);
const [lkResult, ovResult] = await Promise.allSettled([
this.livekitService.listRooms(),
this.listOpenViduRooms()
]);
let lkRooms: Room[] = [];
let ovRooms: OpenViduMeetRoom[] = [];
if (lkResult.status === 'fulfilled') {
lkRooms = lkResult.value;
} else {
this.logger.error('Failed to list Livekit rooms:', lkResult.reason);
}
if (ovResult.status === 'fulfilled') {
ovRooms = ovResult.value;
} else {
this.logger.error('Failed to list OpenVidu rooms:', ovResult.reason);
}
const missingRooms: OpenViduMeetRoom[] = ovRooms.filter(
(ovRoom) => !lkRooms.some((room) => room.name === ovRoom.roomName)
);
if (missingRooms.length === 0) {
this.logger.verbose('All OpenVidu rooms are present in Livekit. No missing rooms to restore. ');
return;
}
this.logger.info(`Restoring ${missingRooms.length} missing rooms`);
const creationResults = await Promise.allSettled(
missingRooms.map((ovRoom) => {
this.logger.debug(`Restoring room: ${ovRoom.roomName}`);
this.createLivekitRoom(ovRoom);
})
);
creationResults.forEach((result, index) => {
if (result.status === 'rejected') {
this.logger.error(`Failed to restore room "${missingRooms[index].roomName}": ${result.reason}`);
} else {
this.logger.info(`Restored room "${missingRooms[index].roomName}"`);
}
});
}
}

View File

@ -0,0 +1,266 @@
import {
_Object,
DeleteObjectCommand,
DeleteObjectCommandOutput,
GetObjectCommand,
GetObjectCommandOutput,
HeadObjectCommand,
HeadObjectCommandOutput,
ListObjectsV2Command,
ListObjectsV2CommandOutput,
PutObjectCommand,
PutObjectCommandOutput,
S3Client,
S3ClientConfig
} from '@aws-sdk/client-s3';
import {
MEET_S3_ACCESS_KEY,
MEET_AWS_REGION,
MEET_S3_BUCKET,
MEET_S3_SERVICE_ENDPOINT,
MEET_S3_SECRET_KEY,
MEET_S3_WITH_PATH_STYLE_ACCESS
} from '../environment.js';
import { errorS3NotAvailable, internalError } from '../models/error.model.js';
import { Readable } from 'stream';
import { LoggerService } from './logger.service.js';
import { inject, injectable } from '../config/dependency-injector.config.js';
@injectable()
export class S3Service {
protected s3: S3Client;
constructor(@inject(LoggerService) protected logger: LoggerService) {
console.log('CE S3Service constructor');
const config: S3ClientConfig = {
region: MEET_AWS_REGION,
credentials: {
accessKeyId: MEET_S3_ACCESS_KEY,
secretAccessKey: MEET_S3_SECRET_KEY
},
endpoint: MEET_S3_SERVICE_ENDPOINT,
forcePathStyle: MEET_S3_WITH_PATH_STYLE_ACCESS === 'true'
};
this.s3 = new S3Client(config);
}
/**
* Checks if a file exists in the specified S3 bucket.
*
* @param path - The path of the file to check.
* @param MEET_AWS_S3_BUCKET - The name of the S3 bucket.
* @returns A boolean indicating whether the file exists or not.
*/
async exists(path: string, bucket: string = MEET_S3_BUCKET) {
try {
await this.getHeaderObject(path, bucket);
return true;
} catch (error) {
return false;
}
}
// copyObject(
// path: string,
// newFileName: string,
// bucket: string = MEET_AWS_S3_BUCKET
// ): Promise<CopyObjectCommandOutput> {
// const newKey = path.replace(path.substring(path.lastIndexOf('/') + 1), newFileName);
// const command = new CopyObjectCommand({
// Bucket: bucket,
// CopySource: `${MEET_AWS_S3_BUCKET}/${path}`,
// Key: newKey
// });
// return this.run(command);
// }
async saveObject(name: string, body: any, bucket: string = MEET_S3_BUCKET): Promise<PutObjectCommandOutput> {
try {
const command = new PutObjectCommand({
Bucket: bucket,
Key: name,
Body: JSON.stringify(body)
});
return await this.run(command);
} catch (error: any) {
this.logger.error(`Error putting object in S3: ${error}`);
if (error.code === 'ECONNREFUSED') {
throw errorS3NotAvailable(error);
}
throw internalError(error);
}
}
/**
* Deletes an object from an S3 bucket.
*
* @param name - The name of the object to delete.
* @param bucket - The name of the S3 bucket (optional, defaults to MEET_S3_BUCKET).
* @returns A promise that resolves to the result of the delete operation.
* @throws Throws an error if there was an error deleting the object.
*/
async deleteObject(name: string, bucket: string = MEET_S3_BUCKET): Promise<DeleteObjectCommandOutput> {
try {
this.logger.verbose(`Deleting object in S3: ${name}`);
const command = new DeleteObjectCommand({ Bucket: bucket, Key: name });
return await this.run(command);
} catch (error) {
this.logger.error(`Error deleting object in S3: ${error}`);
throw internalError(error);
}
}
/**
* Lists all objects in an S3 bucket with optional subbucket and search pattern filtering.
*
* @param {string} [subbucket=''] - The subbucket within the main bucket to list objects from.
* @param {string} [searchPattern=''] - A regex pattern to filter the objects by their keys.
* @param {string} [bucket=MEET_S3_BUCKET] - The name of the S3 bucket. Defaults to MEET_S3_BUCKET.
* @param {number} [maxObjects=1000] - The maximum number of objects to retrieve in one request. Defaults to 1000.
* @returns {Promise<ListObjectsV2CommandOutput>} - A promise that resolves to the output of the ListObjectsV2Command.
* @throws {Error} - Throws an error if there is an issue listing the objects.
*/
async listObjects(
subbucket = '',
searchPattern = '',
bucket: string = MEET_S3_BUCKET,
maxObjects = 1000
): Promise<ListObjectsV2CommandOutput> {
const prefix = subbucket ? `${subbucket}/` : '';
let allContents: _Object[] = [];
let continuationToken: string | undefined = undefined;
let isTruncated = true;
let fullResponse: ListObjectsV2CommandOutput | undefined = undefined;
try {
while (isTruncated) {
const command = new ListObjectsV2Command({
Bucket: bucket,
Prefix: prefix,
MaxKeys: maxObjects,
ContinuationToken: continuationToken
});
const response: ListObjectsV2CommandOutput = await this.run(command);
if (!fullResponse) {
fullResponse = response;
}
// Filter the objects by the search pattern if it is provided
let objects = response.Contents || [];
if (searchPattern) {
const regex = new RegExp(searchPattern);
objects = objects.filter((object) => object.Key && regex.test(object.Key));
}
// Add the objects to the list of all objects
allContents = allContents.concat(objects);
// Update the loop control variables
isTruncated = response.IsTruncated ?? false;
continuationToken = response.NextContinuationToken;
}
if (fullResponse) {
fullResponse.Contents = allContents;
fullResponse.IsTruncated = false;
fullResponse.NextContinuationToken = undefined;
fullResponse.MaxKeys = allContents.length;
fullResponse.KeyCount = allContents.length;
}
return fullResponse!;
} catch (error) {
this.logger.error(`Error listing objects: ${error}`);
if ((error as any).code === 'ECONNREFUSED') {
throw errorS3NotAvailable(error);
}
throw internalError(error);
}
}
async getObjectAsJson(name: string, bucket: string = MEET_S3_BUCKET): Promise<Object | undefined> {
try {
const obj = await this.getObject(name, bucket);
const str = await obj.Body?.transformToString();
return JSON.parse(str as string);
} catch (error: any) {
if (error.name === 'NoSuchKey') {
this.logger.warn(`Object '${name}' does not exist in S3`);
return undefined;
}
if (error.code === 'ECONNREFUSED') {
throw errorS3NotAvailable(error);
}
this.logger.error(`Error getting object from S3. Maybe it has been deleted: ${error}`);
throw internalError(error);
}
}
async getObjectAsStream(name: string, bucket: string = MEET_S3_BUCKET, range?: { start: number; end: number }) {
try {
const obj = await this.getObject(name, bucket, range);
if (obj.Body) {
return obj.Body as Readable;
} else {
throw new Error('Empty body response');
}
} catch (error: any) {
this.logger.error(`Error getting object from S3: ${error}`);
if (error.code === 'ECONNREFUSED') {
throw errorS3NotAvailable(error);
}
throw internalError(error);
}
}
async getHeaderObject(name: string, bucket: string = MEET_S3_BUCKET): Promise<HeadObjectCommandOutput> {
try {
const headParams: HeadObjectCommand = new HeadObjectCommand({
Bucket: bucket,
Key: name
});
return await this.run(headParams);
} catch (error) {
this.logger.error(`Error getting header object from S3 in ${name}: ${error}`);
throw internalError(error);
}
}
quit() {
this.s3.destroy();
}
private async getObject(
name: string,
bucket: string = MEET_S3_BUCKET,
range?: { start: number; end: number }
): Promise<GetObjectCommandOutput> {
const command = new GetObjectCommand({
Bucket: bucket,
Key: name,
Range: range ? `bytes=${range.start}-${range.end}` : undefined
});
return await this.run(command);
}
protected async run(command: any) {
return this.s3.send(command);
}
}

View File

@ -0,0 +1,15 @@
import { inject, injectable } from 'inversify';
import { RedisService } from './redis.service.js';
import { LoggerService } from './logger.service.js';
@injectable()
export class SystemEventService {
constructor(
@inject(LoggerService) protected logger: LoggerService,
@inject(RedisService) protected redisService: RedisService
) {}
onRedisReady(callback: () => void) {
this.redisService.onReady(callback);
}
}

View File

@ -0,0 +1,56 @@
import { inject, injectable } from 'inversify';
import { LoggerService } from './index.js';
import { SystemEventService } from './system-event.service.js';
import { CronJob } from 'cron';
import { MutexService } from './mutex.service.js';
@injectable()
export class TaskSchedulerService {
protected roomGarbageCollectorJob: CronJob | null = null;
constructor(
@inject(LoggerService) protected logger: LoggerService,
@inject(SystemEventService) protected systemEventService: SystemEventService,
@inject(MutexService) protected mutexService: MutexService
) {}
/**
* Starts the room garbage collector which runs a specified callback function every hour.
* The garbage collector acquires a lock to ensure that only one instance runs at a time.
* If a lock cannot be acquired, the garbage collection is skipped for that hour.
*
* @param callbackFn - The callback function to be executed for garbage collection.
* @returns A promise that resolves when the garbage collector has been successfully started.
*/
async startRoomGarbageCollector(callbackFn: () => Promise<void>): Promise<void> {
const lockName = 'room-garbage-lock';
const lockTtl = 59 * 60 * 1000; // TTL of 59 minutes
if (this.roomGarbageCollectorJob) {
this.roomGarbageCollectorJob.stop();
this.roomGarbageCollectorJob = null;
}
// Create a cron job to run every hour
this.roomGarbageCollectorJob = new CronJob('0 * * * *', async () => {
try {
const lock = await this.mutexService.acquire(lockName, lockTtl);
if (!lock) {
this.logger.debug('Failed to acquire lock for room garbage collection. Skipping.');
return;
}
this.logger.debug('Lock acquired for room garbage collection.');
await callbackFn();
} catch (error) {
this.logger.error('Error running room garbage collection:', error);
}
});
// Start the job
this.logger.debug('Starting room garbage collector');
this.roomGarbageCollectorJob.start();
}
}

View File

@ -0,0 +1,57 @@
import {
MEET_ACCESS_TOKEN_EXPIRATION,
MEET_REFRESH_TOKEN_EXPIRATION,
MEET_API_BASE_PATH_V1,
LIVEKIT_API_KEY,
LIVEKIT_API_SECRET
} from '../environment.js';
import { injectable } from '../config/dependency-injector.config.js';
import { CookieOptions } from 'express';
import { AccessToken, AccessTokenOptions, ClaimGrants, TokenVerifier } from 'livekit-server-sdk';
import ms, { StringValue } from 'ms';
@injectable()
export class TokenService {
async generateAccessToken(username: string): Promise<string> {
return await this.generateJwtToken(username, MEET_ACCESS_TOKEN_EXPIRATION);
}
async generateRefreshToken(username: string): Promise<string> {
return await this.generateJwtToken(username, MEET_REFRESH_TOKEN_EXPIRATION);
}
private async generateJwtToken(username: string, expiration: string): Promise<string> {
const options: AccessTokenOptions = {
identity: username,
ttl: expiration,
metadata: JSON.stringify({
role: 'admin'
})
};
const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, options);
return await at.toJwt();
}
async verifyToken(token: string): Promise<ClaimGrants> {
const verifyer = new TokenVerifier(LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
return await verifyer.verify(token);
}
getAccessTokenCookieOptions(): CookieOptions {
return this.getCookieOptions('/', MEET_ACCESS_TOKEN_EXPIRATION);
}
getRefreshTokenCookieOptions(): CookieOptions {
return this.getCookieOptions(`${MEET_API_BASE_PATH_V1}/auth/admin`, MEET_REFRESH_TOKEN_EXPIRATION);
}
private getCookieOptions(path: string, expiration: string): CookieOptions {
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: ms(expiration as StringValue),
path
};
}
}

View File

@ -0,0 +1,27 @@
import { fileURLToPath } from 'url';
import path from 'path';
import * as fs from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Path to the source code
const srcPath = path.resolve(__dirname, '..');
const publicFilesPath = path.join(srcPath, '../public');
const webcomponentBundlePath = path.join(srcPath, '../public/webcomponent/openvidu-meet.bundle.min.js');
const indexHtmlPath = path.join(publicFilesPath, 'index.html');
const getOpenApiSpecPath = () => {
const defaultPath = 'openapi/openvidu-meet-api.yaml';
const fallbackPath = path.resolve(__dirname, '../../../openapi/openvidu-meet-api.yaml');
if (fs.existsSync(defaultPath)) {
return defaultPath;
} else {
console.warn(`Falling back to loading YAML from ${fallbackPath}`);
return fallbackPath;
}
};
export { srcPath, publicFilesPath, indexHtmlPath, webcomponentBundlePath, getOpenApiSpecPath };

View File

@ -0,0 +1,165 @@
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, jest } from '@jest/globals';
import { Express } from 'express';
import { startTestServer, stopTestServer } from '../../../utils/server-setup.js';
import { container } from '../../../../src/config/dependency-injector.config.js';
import { LiveKitService } from '../../../../src/services/livekit.service.js';
import { LoggerService } from '../../../../src/services/logger.service.js';
const apiVersion = 'v1';
const baseUrl = `/embedded/api/`;
const endpoint = '/participant';
describe('Embedded Auth API Tests', () => {
let app: Express;
beforeAll(async () => {
console.log('Server not started. Running in test mode.');
app = await startTestServer();
});
afterAll(async () => {
await stopTestServer();
});
it('✅ Should generate a embedded url with valid input', async () => {
console.log;
const response = await request(app)
.post(`${baseUrl}${apiVersion}${endpoint}`)
.send({
participantName: 'OpenVidu',
roomName: 'TestRoom'
})
.expect(200);
expect(response.body).toHaveProperty('embeddedURL');
expect(typeof response.body.embeddedURL).toBe('string');
});
it('✅ Should generate an embedded url with valid input and some permissions', async () => {
const response = await request(app)
.post(`${baseUrl}${apiVersion}${endpoint}`)
.send({
participantName: 'OpenVidu',
roomName: 'TestRoom',
permissions: {
canRecord: true,
canChat: false
}
})
.expect(200);
expect(response.body).toHaveProperty('embeddedURL');
expect(typeof response.body.embeddedURL).toBe('string');
});
it('❌ Should return 400 when missing participantName', async () => {
const response = await request(app)
.post(`${baseUrl}${apiVersion}${endpoint}`)
.send({
roomName: 'TestRoom'
})
.expect(400);
expect(response.body).toHaveProperty('errors');
expect(response.body.errors[0].message).toContain("must have required property 'participantName'");
});
it('❌ Should return 400 when missing roomName', async () => {
const response = await request(app)
.post(`${baseUrl}${apiVersion}${endpoint}`)
.send({
participantName: 'OpenVidu'
})
.expect(400);
expect(response.body).toHaveProperty('errors');
expect(response.body.errors[0].message).toContain("must have required property 'roomName'");
});
it('❌ Should return 400 when participantName has wrong type', async () => {
const response = await request(app)
.post(`${baseUrl}${apiVersion}${endpoint}`)
.send({
participantName: 22,
roomName: 'TestRoom'
})
.expect(400);
expect(response.body).toHaveProperty('errors');
expect(response.body.errors[0].message).toContain('must be string');
});
it('❌ Should return 400 when missing body request', async () => {
const response = await request(app).post(`${baseUrl}${apiVersion}${endpoint}`).send().expect(415);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toContain('Unsupported Media Type');
});
it('❌ Should return 500 when an error occurs in generateToken', async () => {
jest.mock('../../../src/services/livekit.service');
jest.mock('../../../src/services/logger.service');
const mockLiveKitService = container.get(LiveKitService);
mockLiveKitService.generateToken = jest
.fn()
.mockRejectedValue(new Error('LiveKit Error') as never) as jest.MockedFunction<
(options: any) => Promise<string>
>;
// Mock the logger service
const mockLoggerService = container.get(LoggerService);
mockLoggerService.error = jest.fn();
const response = await request(app).post(`${baseUrl}${apiVersion}${endpoint}`).send({
participantName: 'testParticipant',
roomName: 'testRoom'
});
// Assert: Check that the status is 500 and error message is correct
expect(response.status).toBe(500);
expect(response.body.error).toBe('Internal server error');
expect(mockLoggerService.error).toHaveBeenCalledWith('Internal server error: Error: LiveKit Error');
});
it('❌ Should return 400 when permissions have wrong types', async () => {
const response = await request(app)
.post(`${baseUrl}${apiVersion}${endpoint}`)
.send({
participantName: 'OpenVidu',
roomName: 'TestRoom',
permissions: {
canRecord: 'yes', // Incorrect type
canChat: true
}
})
.expect(400);
expect(response.body).toHaveProperty('errors');
expect(response.body.errors[0].message).toContain('must be boolean');
});
it('❌ Should return 404 when requesting a non-existent API version (v2)', async () => {
const response = await request(app)
.post(`${baseUrl}v2${endpoint}`)
.send({
participantName: 'OpenVidu',
roomName: 'TestRoom'
})
.expect(404);
console.log(response.body);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toBe('Not found');
});
it('❌ Should return 415 when unsupported content type is provided', async () => {
const response = await request(app)
.post(`${baseUrl}${apiVersion}${endpoint}`)
.set('Content-Type', 'application/xml') // Unsupported content type
.send('<xml><participantName>OpenVidu</participantName><roomName>TestRoom</roomName></xml>')
.expect(415);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toContain('Unsupported Media Type');
});
});

View File

@ -0,0 +1,172 @@
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, jest } from '@jest/globals';
import { Express } from 'express';
import { startTestServer, stopTestServer } from '../../../utils/server-setup.js';
import { Room } from 'livekit-server-sdk';
import { container } from '../../../../src/config/dependency-injector.config.js';
import { LiveKitService } from '../../../../src/services/livekit.service.js';
import { LoggerService } from '../../../../src/services/logger.service.js';
const apiVersion = 'v1';
const baseUrl = `/meet/api/`;
const endpoint = '/rooms';
describe('Room Request Validation Tests', () => {
let app: Express;
beforeAll(async () => {
app = await startTestServer();
});
afterAll(async () => {
await stopTestServer();
});
it('✅ Should create a room with only required fields', async () => {
const response = await request(app)
.post(`${baseUrl}${apiVersion}${endpoint}`)
.send({
expirationDate: 1772129829000
})
.expect(200);
expect(response.body).toHaveProperty('creationDate');
expect(response.body).toHaveProperty('expirationDate');
expect(response.body).toHaveProperty('maxParticipants');
expect(response.body).toHaveProperty('preferences');
expect(response.body).toHaveProperty('moderatorRoomUrl');
expect(response.body).toHaveProperty('publisherRoomUrl');
expect(response.body).toHaveProperty('viewerRoomUrl');
expect(response.body).not.toHaveProperty('permissions');
});
it('✅ Should create a room with full attributes', async () => {
const response = await request(app)
.post(`${baseUrl}${apiVersion}${endpoint}`)
.send({
expirationDate: 1772129829000,
roomNamePrefix: 'Conference',
maxParticipants: 10,
preferences: {
recordingPreferences: { enabled: true },
chatPreferences: { enabled: false },
virtualBackgroundPreferences: { enabled: true }
}
})
.expect(200);
expect(response.body).toHaveProperty('creationDate');
expect(response.body).toHaveProperty('expirationDate');
expect(response.body).toHaveProperty('maxParticipants');
expect(response.body).toHaveProperty('preferences');
expect(response.body).toHaveProperty('moderatorRoomUrl');
expect(response.body).toHaveProperty('publisherRoomUrl');
expect(response.body).toHaveProperty('viewerRoomUrl');
expect(response.body).not.toHaveProperty('permissions');
});
it('✅ Should use default values for missing optional fields', async () => {
const response = await request(app)
.post(`${baseUrl}${apiVersion}${endpoint}`)
.send({
expirationDate: 1772129829000
})
.expect(200);
expect(response.body).toHaveProperty('preferences');
expect(response.body.preferences).toEqual({
recordingPreferences: { enabled: true },
¡ chatPreferences: { enabled: true },
virtualBackgroundPreferences: { enabled: true }
});
});
it('❌ Should return 422 when missing expirationDate', async () => {
const response = await request(app).post(`${baseUrl}${apiVersion}${endpoint}`).send({}).expect(422);
expect(response.body).toHaveProperty('error', 'Unprocessable Entity');
expect(response.body.details[0].field).toBe('expirationDate');
expect(response.body.details[0].message).toContain('Required');
});
it('❌ Should return 422 when expirationDate is in the past', async () => {
const response = await request(app)
.post(`${baseUrl}${apiVersion}${endpoint}`)
.send({
expirationDate: 1600000000000
})
.expect(422);
expect(response.body.details[0].message).toContain('Expiration date must be in the future');
});
it('❌ Should return 422 when maxParticipants is negative', async () => {
const response = await request(app)
.post(`${baseUrl}${apiVersion}${endpoint}`)
.send({
expirationDate: 1772129829000,
maxParticipants: -5
})
.expect(422);
expect(response.body.details[0].field).toBe('maxParticipants');
expect(response.body.details[0].message).toContain('Max participants must be a positive integer');
});
it('❌ Should return 422 when maxParticipants is not a number', async () => {
const response = await request(app)
.post(`${baseUrl}${apiVersion}${endpoint}`)
.send({
expirationDate: 1772129829000,
maxParticipants: 'ten'
})
.expect(422);
expect(response.body.details[0].message).toContain('Expected number, received string');
});
it('❌ Should return 422 when expirationDate is not a number', async () => {
const response = await request(app)
.post(`${baseUrl}${apiVersion}${endpoint}`)
.send({
expirationDate: 'tomorrow'
})
.expect(422);
expect(response.body.details[0].message).toContain('Expected number, received string');
});
it('❌ Should return 422 when preferences contain wrong types', async () => {
const response = await request(app)
.post(`${baseUrl}${apiVersion}${endpoint}`)
.send({
expirationDate: 1772129829000,
preferences: {
recordingPreferences: { enabled: 'yes' },
chatPreferences: { enabled: 'no' }
}
})
.expect(422);
expect(response.body.details[0].message).toContain('Expected boolean, received string');
});
it('❌ Should return 500 when an internal server error occurs', async () => {
jest.mock('../../../../src/services/livekit.service');
jest.mock('../../../../src/services/logger.service');
const mockLiveKitService = container.get(LiveKitService);
mockLiveKitService.createRoom = jest.fn<() => Promise<Room>>().mockRejectedValue(new Error('LiveKit Error'));
const mockLoggerService = container.get(LoggerService);
mockLoggerService.error = jest.fn();
const response = await request(app).post(`${baseUrl}${apiVersion}${endpoint}`).send({
expirationDate: 1772129829000,
roomNamePrefix: 'OpenVidu'
});
expect(response.status).toBe(500);
expect(response.body.message).toContain('Internal server error');
});
});

View File

@ -0,0 +1,84 @@
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, jest, afterEach } from '@jest/globals';
import { Express } from 'express';
import { startTestServer, stopTestServer } from '../../../utils/server-setup.js';
import { container } from '../../../../src/config/dependency-injector.config.js';
import { LiveKitService } from '../../../../src/services/livekit.service.js';
import { LoggerService } from '../../../../src/services/logger.service.js';
import { Room } from 'livekit-server-sdk';
const apiVersion = 'v1';
const baseUrl = `/meet/api/`;
const endpoint = '/rooms';
describe('OpenVidu Meet Room API Tests', () => {
let app: Express;
beforeAll(async () => {
console.log('Server not started. Running in test mode.');
app = await startTestServer();
});
afterEach(async () => {
const rooms = await request(app).get(`${baseUrl}${apiVersion}${endpoint}`);
for (const room of rooms.body) {
console.log(`Deleting room ${room.roomName}`);
await request(app).delete(`${baseUrl}${apiVersion}${endpoint}/${room.roomName}`);
}
});
afterAll(async () => {
await stopTestServer();
});
it('Should create a room', async () => {
const response = await request(app)
.post(`${baseUrl}${apiVersion}${endpoint}`)
.send({
expirationDate: 1772129829000,
roomNamePrefix: 'OpenVidu',
maxParticipants: 10,
preferences: {
chatPreferences: { enabled: true },
recordingPreferences: { enabled: true },
virtualBackgroundPreferences: { enabled: true }
}
})
.expect(200);
expect(response.body).toHaveProperty('creationDate');
expect(response.body).toHaveProperty('expirationDate');
expect(response.body).toHaveProperty('maxParticipants');
expect(response.body).toHaveProperty('preferences');
expect(response.body).toHaveProperty('moderatorRoomUrl');
expect(response.body).toHaveProperty('publisherRoomUrl');
expect(response.body).toHaveProperty('viewerRoomUrl');
expect(response.body).not.toHaveProperty('permissions');
const room = await request(app).get(`${baseUrl}${apiVersion}${endpoint}/${response.body.roomName}`).expect(200);
expect(room.body).toHaveProperty('creationDate');
expect(room.body.roomName).toBe(response.body.roomName);
});
it('❌ Should return 500 when an internal server error occurs', async () => {
jest.mock('../../../../src/services/livekit.service');
jest.mock('../../../../src/services/logger.service');
const mockLiveKitService = container.get(LiveKitService);
mockLiveKitService.createRoom = jest.fn<() => Promise<Room>>().mockRejectedValue(new Error('LiveKit Error'));
const mockLoggerService = container.get(LoggerService);
mockLoggerService.error = jest.fn();
const response = await request(app).post(`${baseUrl}${apiVersion}${endpoint}`).send({
expirationDate: 1772129829000,
roomNamePrefix: 'OpenVidu'
});
expect(response.status).toBe(500);
expect(response.body.message).toContain('Internal server error');
});
});

View File

@ -0,0 +1,11 @@
import { Room } from 'livekit-server-sdk';
import { LiveKitService } from '../../src/services/livekit.service.js';
import { jest } from '@jest/globals';
// Mock para LiveKitService
export const mockLiveKitService = {
createRoom:
// Añade más mocks si es necesario
};
// Puedes hacer un mock de otras funciones también si es necesario

View File

@ -0,0 +1,108 @@
import fetchMock from 'jest-fetch-mock';
import { OpenViduWebhookService } from '../../../src/services/openvidu-webhook.service';
import { LoggerService } from '../../../src/services/logger.service';
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
import { Room } from 'livekit-server-sdk';
import { OpenViduWebhookEvent } from '../../../src/models/webhook.model';
import { MEET_WEBHOOK_URL } from '../../../src/environment';
describe('OpenVidu Webhook Service', () => {
let webhookService: OpenViduWebhookService;
let loggerMock: jest.Mocked<LoggerService>;
beforeEach(() => {
fetchMock.enableMocks();
// Create a new instance of LoggerService before each test
loggerMock = {
verbose: jest.fn(),
error: jest.fn()
} as unknown as jest.Mocked<LoggerService>;
// Create a new instance of OpenViduWebhookService before each test
webhookService = new OpenViduWebhookService(loggerMock);
});
afterEach(() => {
jest.clearAllMocks();
fetchMock.resetMocks();
jest.useRealTimers();
});
it('should not send webhook if webhook is disabled', async () => {
jest.spyOn(webhookService as any, 'isWebhookEnabled').mockReturnValue(false);
const mockRoom = { name: 'TestRoom' } as Room;
await webhookService.sendRoomFinishedWebhook(mockRoom);
expect(fetch).not.toHaveBeenCalled();
expect(loggerMock.verbose).not.toHaveBeenCalled();
});
it('should send webhook when enabled and request is successful', async () => {
jest.spyOn(webhookService as any, 'isWebhookEnabled').mockReturnValue(true);
(fetch as jest.MockedFunction<typeof fetch>).mockResolvedValue(new Response(null, { status: 200 }));
const mockRoom = { name: 'TestRoom' } as Room;
await webhookService.sendRoomFinishedWebhook(mockRoom);
expect(loggerMock.verbose).toHaveBeenCalledWith(`Sending room finished webhook for room ${mockRoom.name}`);
expect(fetch).toHaveBeenCalledWith(MEET_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: OpenViduWebhookEvent.ROOM_FINISHED,
room: { name: mockRoom.name }
})
});
});
it('should retry sending webhook on failure and eventually succeed', async () => {
jest.spyOn(webhookService as any, 'isWebhookEnabled').mockReturnValue(true);
// Set fetch to fail twice before succeeding
(fetch as jest.MockedFunction<typeof fetch>)
.mockRejectedValueOnce(new Error('Network Error'))
.mockRejectedValueOnce(new Error('Network Error'))
.mockResolvedValue(new Response(null, { status: 200 }));
const mockRoom = { name: 'TestRoom' } as Room;
await webhookService.sendRoomFinishedWebhook(mockRoom);
expect(loggerMock.verbose).toHaveBeenCalledWith(`Sending room finished webhook for room ${mockRoom.name}`);
expect(loggerMock.verbose).toHaveBeenCalledWith('Retrying in 0.3 seconds... (5 retries left)');
expect(loggerMock.verbose).toHaveBeenCalledWith('Retrying in 0.6 seconds... (4 retries left)');
expect(fetch).toHaveBeenCalledTimes(3);
});
it('should throw error after exhausting all retries', async () => {
jest.useFakeTimers({ advanceTimers: true });
jest.spyOn(webhookService as any, 'isWebhookEnabled').mockReturnValue(true);
// Set fetch to always fail
(fetch as jest.MockedFunction<typeof fetch>).mockRejectedValue(new Error('Network Error'));
const mockRoom = { name: 'TestRoom' } as Room;
const sendPromise = webhookService.sendRoomFinishedWebhook(mockRoom);
for (const delay of [300, 600, 1200, 2400, 4800]) {
jest.advanceTimersByTime(delay);
await new Promise(process.nextTick);
}
await expect(sendPromise).rejects.toThrow('Request failed: Error: Network Error');
jest.useRealTimers();
expect(fetch).toHaveBeenCalledTimes(6);
});
});

View File

@ -0,0 +1,74 @@
// tests/integration/services/system-event.service.test.ts
import 'reflect-metadata';
import { Container } from 'inversify';
import { SystemEventService } from '../../../src/services/system-event.service';
import { RedisService } from '../../../src/services/redis.service';
import { LoggerService } from '../../../src/services/logger.service';
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
describe('SystemEventService', () => {
let container: Container;
let systemEventService: SystemEventService;
let redisServiceMock: jest.Mocked<RedisService>;
let loggerMock: jest.Mocked<LoggerService>;
beforeEach(() => {
// Crear mocks para RedisService y LoggerService
redisServiceMock = {
onReady: jest.fn()
// Añadir otros métodos de RedisService si existen
} as unknown as jest.Mocked<RedisService>;
loggerMock = {
verbose: jest.fn(),
error: jest.fn()
// Añadir otros métodos de LoggerService si existen
} as unknown as jest.Mocked<LoggerService>;
// Configurar el contenedor
container = new Container();
container.bind<LoggerService>(LoggerService).toConstantValue(loggerMock);
container.bind<RedisService>(RedisService).toConstantValue(redisServiceMock);
container.bind<SystemEventService>(SystemEventService).toSelf();
// Obtener instancia del servicio
systemEventService = container.get(SystemEventService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('debería registrar el callback en RedisService.onReady', () => {
const callback = jest.fn();
systemEventService.onRedisReady(callback);
expect(redisServiceMock.onReady).toHaveBeenCalledWith(callback);
});
it('puede registrar múltiples callbacks en RedisService.onReady', () => {
const callback1 = jest.fn();
const callback2 = jest.fn();
systemEventService.onRedisReady(callback1);
systemEventService.onRedisReady(callback2);
expect(redisServiceMock.onReady).toHaveBeenCalledTimes(2);
expect(redisServiceMock.onReady).toHaveBeenCalledWith(callback1);
expect(redisServiceMock.onReady).toHaveBeenCalledWith(callback2);
});
it('debería manejar errores al registrar callbacks', () => {
const callback = jest.fn();
const error = new Error('Error al registrar el callback');
redisServiceMock.onReady.mockImplementationOnce(() => {
throw error;
});
expect(() => systemEventService.onRedisReady(callback)).toThrow(error);
expect(redisServiceMock.onReady).toHaveBeenCalledWith(callback);
});
});

View File

@ -0,0 +1,60 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createApp, registerDependencies, initializeGlobalPreferences } from '../../src/server.js';
import request from 'supertest';
import { Express } from 'express';
import { SERVER_PORT } from '../../src/environment.js';
import { Server } from 'http';
let server: Server
const baseUrl = '/meet/health';
export const startTestServer = async (): Promise<Express> => {
registerDependencies();
const app = createApp();
return await new Promise<Express>((resolve, reject) => {
server = app.listen(SERVER_PORT, async () => {
try {
// Initialize global preferences once the server is ready
await initializeGlobalPreferences();
// Check if the server is responding by hitting the health check route
const response = await request(app).get(baseUrl);
if (response.status === 200) {
console.log('Test server started and healthy!');
resolve(app);
} else {
reject(new Error('Test server not healthy'));
}
} catch (error: any) {
reject(new Error('Failed to initialize server or global preferences: ' + error.message));
}
});
// Handle server errors
server.on('error', (error: any) => reject(new Error(`Test server startup error: ${error.message}`)));
});
};
/**
* Stops the test server.
* It will call `server.close()` to gracefully shut down the server.
*/
export const stopTestServer = async (): Promise<void> => {
if (server) {
return new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(new Error(`Failed to stop server: ${err.message}`));
} else {
console.log('Test server stopped.');
resolve();
}
});
});
} else {
console.log('Server is not running.');
}
};

22
backend/tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ESNEXT" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */,
"module": "NodeNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
"declaration": true /* Generates corresponding '.d.ts' file. */,
"declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */,
"sourceMap": true /* Generates corresponding '.map' file. */,
"outDir": "./dist" /* Redirect output structure to the directory. */,
"rootDir": "." /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
"esModuleInterop": true,
"strict": true /* Enable all strict type-checking options. */,
"skipLibCheck": true /* Skip type checking of declaration files. */,
"forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
"experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
"emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
"paths": {
"@typings-ce": ["./src/typings/ce/index.ts"]
}
},
"include": ["src/**/*.ts", "index.ts"],
"exclude": ["node_modules", "tests/**/*.ts", "tests/**/*.tsx"],
}

View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noUnusedLocals": true,
"skipLibCheck": true
},
"exclude": ["**/*.test.ts", "*.test.tsx"]
}

75
docker/Dockerfile Normal file
View File

@ -0,0 +1,75 @@
### Stage 0: Build common types library
FROM node:20 as build-common-types
USER node
WORKDIR /app/meet-common-types
COPY --chown=node:node types/ ./
RUN npm install && \
npm run build:prod && \
npm pack && \
rm -rf node_modules dist
### Stage 1: Build the frontend
FROM node:20 as build-frontend
USER node
WORKDIR /app/frontend
ARG BASE_HREF=/
COPY --chown=node:node frontend/ ./
COPY --from=build-common-types /app/meet-common-types/openvidu-meet-common-types-**.tgz ./
RUN npm install && \
npm run lib:build && \
npm run lib:pack && \
mv dist/shared-meet-components/shared-meet-components-**.tgz . && \
npm install shared-meet-components-**.tgz && \
npm run build:prod ${BASE_HREF}
### Stage 2: Build the backend
FROM node:20 as build-backend
USER node
WORKDIR /app/backend
COPY --chown=node:node backend/package*.json ./
COPY --from=build-common-types /app/meet-common-types/openvidu-meet-common-types-**.tgz ./
RUN npm install
COPY --chown=node:node backend/ ./
RUN mkdir -p /app/backend/dist/src && chown -R node:node /app/backend/dist
# Copy static files from the frontend build
COPY --from=build-frontend /app/frontend/dist/openvidu-meet /app/backend/dist/public
RUN npm run build:prod
### Stage 3: Final production image
FROM node:20-alpine as production
WORKDIR /opt/openvidu-meet
COPY --from=build-common-types /app/meet-common-types/openvidu-meet-common-types-**.tgz ./
COPY --from=build-backend /app/backend/dist ./dist
COPY --from=build-backend /app/backend/package*.json ./
RUN npm install --production && npm cache clean --force
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh && \
chown -R node:node /opt/openvidu-meet
EXPOSE $SERVER_PORT
CMD ["/usr/local/bin/entrypoint.sh"]

10
docker/create_image.sh Executable file
View File

@ -0,0 +1,10 @@
#!/bin/bash -x
IMAGE=${1:-?echo "Error: You need to specify an image name as first argument"?}
if [[ -n $IMAGE ]]; then
cd ..
export BUILDKIT_PROGRESS=plain && docker build --pull --no-cache --rm=true -f docker/Dockerfile -t "$IMAGE" --build-arg BASE_HREF=/ .
echo "Docker image '$IMAGE' built successfully."
else
echo "Error: You need to specify an image name as first argument"
fi

9
docker/create_image_demos.sh Executable file
View File

@ -0,0 +1,9 @@
#!/bin/bash -x
IMAGE="${1:-?echo "Error: You need to specify an image name as first argument"?}"
if [[ -n $IMAGE ]]; then
cd ..
export BUILDKIT_PROGRESS=plain && docker build --pull --no-cache --rm=true -f docker/Dockerfile -t "$IMAGE"-demos --build-arg BASE_HREF=/openvidu-meet/ .
else
echo "Error: You need to specify the image name as first argument"
fi

40
docker/entrypoint.sh Normal file
View File

@ -0,0 +1,40 @@
#!/bin/sh
# Function to handle termination signals
terminate_process() {
echo "Terminating Node.js process..."
pkill -TERM node
}
# Trap termination signals
trap terminate_process TERM INT
# If a custom config directory is not provided,
# check minimal required environment variables
if [ -z "${MEET_CONFIG_DIR}" ]; then
if [ -z "${LIVEKIT_URL}" ]; then
echo "LIVEKIT_URL is required"
echo "example: docker run -e LIVEKIT_URL=https://livekit-server:7880 -e LIVEKIT_API_KEY=api_key -e LIVEKIT_API_SECRET=api_secret -p 6080:6080 openvidu-meet"
exit 1
fi
if [ -z "${LIVEKIT_API_KEY}" ]; then
echo "LIVEKIT_API_KEY is required"
echo "example: docker run -e LIVEKIT_URL=https://livekit-server:7880 -e LIVEKIT_API_KEY=api_key -e LIVEKIT_API_SECRET=api_secret -p 6080:6080 openvidu-meet"
exit 1
fi
if [ -z "${LIVEKIT_API_SECRET}" ]; then
echo "LIVEKIT_API_SECRET is required"
echo "example: docker run -e LIVEKIT_URL=https://livekit-server:7880 -e LIVEKIT_API_KEY=api_key -e LIVEKIT_API_SECRET=api_secret -p 6080:6080 openvidu-meet"
exit 1
fi
fi
cd /opt/openvidu-meet || { echo "Can't cd into /opt/openvidu-meet"; exit 1; }
node dist/src/server.js &
# Save the PID of the Node.js process
node_pid=$!
# Wait for the Node.js process to finish
wait $node_pid

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

72
frontend/.eslintrc.json Normal file
View File

@ -0,0 +1,72 @@
{
"root": true,
"ignorePatterns": ["projects/**/*"],
"overrides": [
{
"files": ["*.ts"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates",
"prettier"
],
"rules": {
"@angular-eslint/directive-selector": [
"warn",
{
"type": "attribute",
"prefix": "app",
"style": "camelCase"
}
],
"@angular-eslint/component-selector": [
"warn",
{
"type": "element",
"prefix": "app",
"style": "kebab-case"
}
],
"@typescript-eslint/no-inferrable-types": "warn",
"@typescript-eslint/no-unused-vars": "warn",
"lines-between-class-members": [
"warn",
{
"enforce": [
{
"blankLine": "always",
"prev": "method",
"next": "method"
}
]
}
],
"padding-line-between-statements": [
"warn",
{
"blankLine": "always",
"prev": "*",
"next": ["if", "for", "while", "switch"]
},
{
"blankLine": "always",
"prev": ["if", "for", "while", "switch"],
"next": "*"
},
{
"blankLine": "always",
"prev": "*",
"next": "block-like"
},
{ "blankLine": "always", "prev": "block-like", "next": "*" }
]
}
},
{
"files": ["*.html"],
"excludedFiles": ["*inline-template-*.component.html"],
"extends": ["plugin:@angular-eslint/template/recommended", "prettier"]
}
]
}

27
frontend/.mocharc.js Normal file
View File

@ -0,0 +1,27 @@
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { register } from 'ts-node';
import path from 'path';
import glob from 'glob';
import { exit } from 'process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
register({
transpileOnly: true,
project: 'tsconfig.test.json'
});
const testFiles = glob.sync(path.resolve('tests/e2e/**/*.test.ts'));
console.log('Tests found:', testFiles);
export default {
extension: ['ts'],
spec: testFiles,
timeout: 30000,
recursive: true,
loader: 'ts-node/esm',
require: ['ts-node/register',],
exit: true
};

10
frontend/.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"singleQuote": true,
"printWidth": 120,
"trailingComma": "none",
"semi": true,
"bracketSpacing": true,
"useTabs": true,
"jsxSingleQuote": true,
"tabWidth": 4
}

8
frontend/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"prettier.jsxSingleQuote": true,
"prettier.singleQuote": true,
"javascript.preferences.quoteStyle": "single",
"typescript.preferences.quoteStyle": "single",
"editor.insertSpaces": false,
"prettier.useTabs": true
}

25
frontend/README.md Normal file
View File

@ -0,0 +1,25 @@
# Openvidu Meet Frontend
This is the frontend of OpenVidu Meet. It is a Angular application that uses Angular Material as UI library.
## How to run
For running the frontend you need to have installed [Node.js](https://nodejs.org/). Then, you can run the following commands:
```bash
npm install
start:dev
```
This will start the frontend in development mode. The server will listen on port 5080.
## How to build
For building the frontend you can run the following command:
```bash
npm install
npm run build:prod
```

179
frontend/angular.json Normal file
View File

@ -0,0 +1,179 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"openvidu-meet": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"prefix": "ov",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"aot": true,
"outputPath": "dist/openvidu-meet",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "src/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss", "src/colors.scss"],
"scripts": []
},
"configurations": {
"development": {
"optimization": false,
"outputHashing": "all",
"sourceMap": true,
"extractLicenses": false,
"outputPath": {
"base": "../backend/public",
"browser": ""
},
"deleteOutputPath": false,
"clearScreen": true
},
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "200kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"aot": true,
"extractLicenses": true
},
"ci": {
"budgets": [
{
"type": "anyComponentStyle",
"maximumWarning": "6kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.ci.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"aot": true,
"extractLicenses": true
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"buildTarget": "openvidu-meet:build",
"proxyConfig": "src/proxy.conf.json"
},
"configurations": {
"development": {
"buildTarget": "openvidu-meet:build:development"
},
"production": {
"buildTarget": "openvidu-meet:build:production"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "openvidu-meet:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": ["zone.js"],
"tsConfig": "src/tsconfig.spec.json",
"karmaConfig": "src/karma.conf.js",
"codeCoverage": true,
"styles": [],
"scripts": [],
"assets": ["src/favicon.ico", "src/assets"],
"codeCoverageExclude": ["/**/*mock*.ts", "/**/openvidu-layout.ts"]
}
}
}
},
"openvidu-meet-e2e": {
"root": "e2e/",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "openvidu-meet:serve"
}
}
}
},
"shared-meet-components": {
"projectType": "library",
"root": "projects/shared-meet-components",
"sourceRoot": "projects/shared-meet-components/src",
"prefix": "ov",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"project": "projects/shared-meet-components/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "projects/shared-meet-components/tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "projects/shared-meet-components/tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"tsConfig": "projects/shared-meet-components/tsconfig.spec.json",
"polyfills": ["zone.js", "zone.js/testing"]
}
}
}
}
}
}

11
frontend/jest.config.mjs Normal file
View File

@ -0,0 +1,11 @@
module.exports = {
roots: ['<rootDir>/src', '<rootDir>/tests'],
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
transform: {
'^.+\\.ts?$': 'ts-jest' // Si usas TypeScript
},
moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json', 'node'],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy' // Para importar estilos en componentes
}
};

17949
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

79
frontend/package.json Normal file
View File

@ -0,0 +1,79 @@
{
"name": "openvidu-meet-frontend",
"version": "3.0.0-beta3",
"private": true,
"scripts": {
"start:dev": "npx ng serve --configuration development --port=5080 --host=0.0.0.0",
"build:dev": "npx ng build --configuration development --watch",
"build:prod": "func() { ./node_modules/@angular/cli/bin/ng.js build --configuration production --base-href=\"${1:-/}\"; }; func",
"sync:backend": "npx ng build --configuration production --output-path ../backend/dist/public/",
"e2e:run-all": "npx mocha --recursive --timeout 30000 ./tests/e2e/**/*.test.ts",
"e2e:run-routes": "npx mocha --recursive --timeout 30000 ./tests/e2e/routes.test.ts",
"e2e:run-home": "npx mocha ./tests/e2e/home.test.ts",
"e2e:run-room": "npx mocha --recursive --timeout 30000 ./tests/e2e/room.test.ts",
"e2e:run-recordings": "npx mocha --recursive --timeout 30000 ./tests/e2e/recording.test.ts",
"e2e:run-auth": "npx mocha --recursive --timeout 30000 ./tests/e2e/auth.test.ts",
"lib:serve": "npx ng build shared-meet-components --watch",
"lib:build": "ng build shared-meet-components --configuration production",
"lib:pack": "cd dist/shared-meet-components && npm pack",
"lib:sync-pro": "npm run lib:build && npm run lib:pack && cp dist/shared-meet-components/shared-meet-components-**.tgz ../../openvidu-meet-pro/frontend",
"test:unit": "ng test openvidu-meet --watch=false --code-coverage",
"webcomponent:unit-test": "node --experimental-vm-modules node_modules/.bin/jest --config jest.config.mjs",
"lint:fix": "eslint src --fix",
"format:code": "prettier --ignore-path ../gitignore . --write"
},
"dependencies": {
"@angular/animations": "18.2.5",
"@angular/cdk": "18.2.5",
"@angular/common": "18.2.5",
"@angular/compiler": "18.2.5",
"@angular/core": "18.2.5",
"@angular/forms": "18.2.5",
"@angular/material": "18.2.5",
"@angular/platform-browser": "18.2.5",
"@angular/platform-browser-dynamic": "18.2.5",
"@angular/router": "18.2.5",
"core-js": "^3.38.1",
"jwt-decode": "^4.0.0",
"livekit-server-sdk": "^2.10.2",
"openvidu-components-angular": "^3.2.0-dev6",
"rxjs": "7.8.1",
"tslib": "^2.3.0",
"unique-names-generator": "^4.7.1",
"zone.js": "0.14.10"
},
"devDependencies": {
"@angular-devkit/build-angular": "18.2.6",
"@angular-eslint/builder": "18.3.1",
"@angular-eslint/eslint-plugin": "18.3.1",
"@angular-eslint/eslint-plugin-template": "18.3.1",
"@angular-eslint/schematics": "18.3.1",
"@angular-eslint/template-parser": "18.3.1",
"@angular/cli": "18.2.5",
"@angular/compiler-cli": "18.2.5",
"@types/chai": "4.3.19",
"@types/fluent-ffmpeg": "2.1.27",
"@types/mocha": "9.1.1",
"@types/node": "20.12.14",
"@types/pixelmatch": "^5.2.6",
"@types/pngjs": "^6.0.5",
"@types/selenium-webdriver": "4.1.26",
"@typescript-eslint/eslint-plugin": "7.18.0",
"@typescript-eslint/parser": "7.18.0",
"chai": "4.3.10",
"chromedriver": "132.0.0",
"cross-env": "7.0.3",
"eslint": "8.57.1",
"eslint-config-prettier": "9.1.0",
"fluent-ffmpeg": "2.1.3",
"mocha": "10.7.3",
"ng-packagr": "18.2.1",
"pixelmatch": "5.3.0",
"pngjs": "7.0.0",
"prettier": "^3.3.3",
"selenium-webdriver": "4.25.0",
"ts-node": "10.9.2",
"tslib": "2.6.3",
"typescript": "5.4.5"
}
}

View File

@ -0,0 +1,24 @@
# SharedMeetComponents
This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.2.0.
## Code scaffolding
Run `ng generate component component-name --project shared-meet-components` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project shared-meet-components`.
> Note: Don't forget to add `--project shared-meet-components` or else it will be added to the default project in your `angular.json` file.
## Build
Run `ng build shared-meet-components` to build the project. The build artifacts will be stored in the `dist/` directory.
## Publishing
After building your library with `ng build shared-meet-components`, go to the dist folder `cd dist/shared-meet-components` and run `npm publish`.
## Running unit tests
Run `ng test shared-meet-components` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@ -0,0 +1,7 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/shared-meet-components",
"lib": {
"entryFile": "src/public-api.ts"
}
}

Some files were not shown because too many files have changed in this diff Show More