加入后端代码

This commit is contained in:
david_bai
2025-05-12 22:48:15 +08:00
commit 3f9be8ff84
25 changed files with 3526 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
# 使用 Ubuntu 20.04 镜像作为基础
FROM ubuntu:20.04
# 设置环境变量,以避免交互式安装
ENV DEBIAN_FRONTEND=noninteractive
# 设置清华大学软件源
RUN sed -i 's/archive.ubuntu.com/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list
RUN apt-get update && apt-get install -y tzdata
# 设置上海时区
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
# 安装 certbot nginx
RUN apt install -y certbot python3-certbot-nginx
# TURN服务器
RUN apt-get install -y vim coturn
# redis服务
RUN apt-get install -y redis-server
# 安装nodejs 20
RUN apt-get install -y curl
# node.js
## Import repository GPG key
RUN apt install -y ca-certificates gnupg && mkdir -p /etc/apt/keyrings
RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
## Add Node.JS 20 LTS APT repository.
ENV NODE_MAJOR=20
RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
## Update package index.
RUN apt-get update
## Install Node.js, npm, pnpm
RUN apt install -y nodejs
RUN npm install -g pnpm
## node -v -> v20.18.1;npm -v -> 10.8.2;pnpm -v -> 9.14.4
## install Yarn package manager
#curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /usr/share/keyrings/yarnkey.gpg >/dev/null
#echo "deb [signed-by=/usr/share/keyrings/yarnkey.gpg] https://dl.yarnpkg.com/debian stable main" | tee /etc/apt/sources.list.d/yarn.list
#apt update && apt-get install yarn -y
#clean up
RUN apt-get clean autoclean
RUN apt-get autoremove --yes
RUN rm -rf /var/lib/{apt,cache,log}/ && rm -rf /tmp/*
+21
View File
@@ -0,0 +1,21 @@
sudo apt install -y certbot python3-certbot-nginx
sudo apt-get install -y vim coturn
sudo apt-get install -y redis-server
sudo apt-get install -y curl
sudo apt install -y ca-certificates gnupg && sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
export NODE_MAJOR=20
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
sudo apt-get update
sudo apt install -y nodejs
sudo npm install -g pnpm
sudo apt-get clean autoclean
sudo apt-get autoremove --yes
sudo rm -rf /var/lib/{apt,cache,log}/ && sudo rm -rf /tmp/*
+5
View File
@@ -0,0 +1,5 @@
cp default /etc/nginx/sites-available/
cp nginx.conf /etc/nginx
nginx -t
/etc/init.d/nginx restart
+126
View File
@@ -0,0 +1,126 @@
server {
# 将 HTTP 重定向到 HTTPS
listen 80;
server_name securityshare.xyz www.securityshare.xyz;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2; # 监听 TCP 端口 443,支持 HTTP/2 和 SSL
listen 443 quic reuseport; # 监听 UDP 端口 443,用于 QUIC 和 HTTP/3
# 'reuseport' 允许多个 worker 进程共享同一个端口,推荐用于 QUIC
# 确保 SSL 协议至少包含 TLSv1.3,因为 HTTP/3 要求 TLSv1.3
ssl_protocols TLSv1.3 TLSv1.2; # 确保 TLSv1.3 在前面
# 添加 HTTP/3 特定的头部,告知浏览器 HTTP/3 可用
# Alt-Svc (Alternative Service) 头部
# h3=":443" 表示 HTTP/3 在当前域名和 443 端口上可用
# ma=86400 表示这个信息缓存 24 小时 (86400 秒)
add_header Alt-Svc 'h3=":443"; ma=86400';
# (可选,但推荐) 启用 0-RTT 数据,可以进一步减少延迟
# 需要客户端和服务器都支持
ssl_early_data on;
server_name securityshare.xyz www.securityshare.xyz;
# SSL 配置
ssl_certificate /etc/letsencrypt/live/securityshare.xyz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/securityshare.xyz/privkey.pem;
# SSL 优化
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
# 现代配置
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# HSTS (谨慎启用)
# add_header Strict-Transport-Security "max-age=63072000" always;
# 定义前端构建产物在容器内的根路径
# !!! 重要: 请将此路径修改为您的前端项目构建后在Nginx容器内的实际路径 !!!
set $frontend_build_root /home/ubuntu/workdir_atbj/clipboard_web;
# 1. 优先处理 Next.js 的核心静态资源 (_next/static)
location /_next/static/ {
alias $frontend_build_root/.next/static/;
expires 365d; # 长时间缓存
access_log off; # 关闭此路径的访问日志
add_header Cache-Control "public"; # 明确告知浏览器可以公开缓存
}
# WebSocket 信令服务器配置
location /socket.io/ {
proxy_pass http://localhost:3001/socket.io/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# CORS 配置
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
# WebSocket 相关优化
proxy_read_timeout 86400; # 24h
proxy_send_timeout 86400; # 24h
proxy_connect_timeout 7d;
proxy_buffering off;
}
# 后端API地址--转发
location /api/ {
proxy_pass http://localhost:3001/api/; # 后端API地址--转发
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 修改 CORS 配置,只设置一个 Origin
add_header Access-Control-Allow-Origin "https://www.securityshare.xyz" always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range" always;
add_header Access-Control-Allow-Credentials "true" always;
}
# Next.js 图片优化服务 (通常由 Next.js 应用处理)
location /_next/image {
proxy_pass http://localhost:3000; # 指向 Next.js 应用
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# 2. 处理 public 目录下的静态文件和 Next.js 动态请求
# 这个 location 应该在特定代理 (如 /api/, /socket.io/) 之后,
# 但在 /_next/static/ 之前或之后都可以,因为它们匹配不同的路径。
# 为了清晰,我们把它放在这里。
location / {
# root 指向 public 目录的父目录,即前端构建产物的根目录
root $frontend_build_root/public;
# 尝试按顺序查找文件:
# 1. $uri: 作为 public 目录下的文件 (例如 /image.png -> $frontend_build_root/public/image.png)
# 2. $uri/: 作为 public 目录下的目录 (通常不直接用于 Next.js public 文件)
# 3. @nextjs: 如果以上都未找到,则将请求传递给 Next.js 应用处理
try_files $uri $uri/ @nextjs_app;
}
# 命名 location, 用于将请求代理到 Next.js 应用
location @nextjs_app {
proxy_pass http://localhost:3000; # 指向 Next.js 应用
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
+88
View File
@@ -0,0 +1,88 @@
#运行用户,需要给文件目录访问权限
user root;
#启动进程,通常设置成和cpu的数量相等
# worker_processes 1;
worker_processes auto;
pid /run/nginx.pid;
#include /etc/nginx/modules-enabled/*.conf;
events {
worker_connections 768;
# multi_accept on;
}
http {
##
# Basic Settings
##
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# server_tokens off;
# server_names_hash_bucket_size 64;
# server_name_in_redirect off;
include /etc/nginx/mime.types;
default_type application/octet-stream;
##
# SSL Settings
##
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
ssl_prefer_server_ciphers on;
##
# Logging Settings
##
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
##
# Gzip Settings
##
gzip on;
# gzip_vary on;
# gzip_proxied any;
# gzip_comp_level 6;
# gzip_buffers 16 8k;
# gzip_http_version 1.1;
# gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
##
# Virtual Host Configs
##
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
#mail {
# # See sample authentication script at:
# # http://wiki.nginx.org/ImapAuthenticateWithApachePhpScript
#
# # auth_http localhost/auth.php;
# # pop3_capabilities "TOP" "USER";
# # imap_capabilities "IMAP4rev1" "UIDPLUS";
#
# server {
# listen localhost:110;
# protocol pop3;
# proxy on;
# }
#
# server {
# listen localhost:143;
# protocol imap;
# proxy on;
# }
#}
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
# 证书监控续期脚本--自动检查,如果少于30天则续期, 手动执行:
# cd /home/ubuntu/workdir_atbj/clipboard_backend_node/docker/nginx && bash renew_ssl.sh
# crontab 自动任务
# chmod +x /home/ubuntu/workdir_atbj/clipboard_backend_node/docker/nginx/renew_ssl.sh
# crontab -e 打开编辑器
# 0 0 * * * bash /home/ubuntu/workdir_atbj/clipboard_backend_node/docker/nginx/renew_ssl.sh >> /home/ubuntu/workdir_atbj/certbot-renew.log 2>&1
# 首先切换到脚本所在目录
cd "$(dirname "$(readlink -f "$0")")" || exit 1
# 定义证书目录
CERTBOT_DIR="/etc/letsencrypt/live"
# 遍历所有证书
for CERT_PATH in "$CERTBOT_DIR"/*/fullchain.pem; do
# 获取域名
DOMAIN=$(basename "$(dirname "$CERT_PATH")")
# 检查证书有效期
DAYS_REMAINING=$(openssl x509 -enddate -noout -in "$CERT_PATH" | cut -d= -f2 | xargs -I{} date -d "{}" +%s)
NOW=$(date +%s)
DAYS=$(( ($DAYS_REMAINING - $NOW) / 86400 ))
echo "Domain: $DOMAIN, Days left: $DAYS days"
# 如果剩余时间少于 30 天,自动续期
if [ $DAYS -lt 30 ]; then
echo "Warning: Certificate for $DOMAIN will expire in $DAYS days. Renewing..."
# 运行续期命令之前要解除80端口占用--暂停ngnix
sudo bash stop_clean-log.sh
# 使用 Certbot 自动续期
sudo certbot renew --force-renewal --cert-name "$DOMAIN"
# 检查续期是否成功
if [ $? -eq 0 ]; then
echo "Renewal successful for $DOMAIN"
else
echo "Failed to renew certificate for $DOMAIN"
fi
# 启动ngnix
sudo bash cp_cfg_run.sh
fi
done
+2
View File
@@ -0,0 +1,2 @@
/etc/init.d/nginx stop
rm /var/log/nginx/*
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# 检查是否指定了环境参数
if [ -z "$1" ]; then
echo "Usage: $0 [0--production|1--development]"
exit 1
fi
# 获取环境参数
ENVIRONMENT=$1
# 根据环境参数执行操作
if [ "$ENVIRONMENT" = "0" ]; then
echo "Switching to production environment..."
cp turnserver_production.conf /etc/turnserver.conf
service coturn restart
echo "Production environment is now active."
elif [ "$ENVIRONMENT" = "1" ]; then
echo "Switching to development environment..."
cp turnserver_development.conf /etc/turnserver.conf
service coturn restart
echo "Development environment is now active."
else
echo "Invalid environment specified: $ENVIRONMENT"
echo "Please specify either '0--production' or '1--development'."
exit 1
fi
@@ -0,0 +1,45 @@
# /etc/turnserver.conf
# 监听所有接口
listening-ip=0.0.0.0
# 使用你的服务器公网IP
external-ip=49.235.189.26
# TURN 服务器端口
listening-port=3478
# 启用 TLS--TURNS(加密的 TURN
#tls-listening-port=5349
# 中继端口范围
min-port=49152
max-port=65535
# 长期证书机制
lt-cred-mech
# TURN 服务器域名(如果有的话)
# realm=turn.securityshare.xyz
realm=49.235.189.26
# TURN 服务器证书和密钥(用于TLS)
# cert=/etc/letsencrypt/live/turn.securityshare.xyz/fullchain.pem
# pkey=/etc/letsencrypt/live/turn.securityshare.xyz/privkey.pem
# 用户名和密码(在生产环境中应使用更安全的方法)
user=secureUser:QWERTY!@#456
# 启用详细日志
verbose
# 允许回环地址
# allow-loopback-peers
# 设置最大带宽(字节/秒)
# max-bandwidth=0
# 禁用 TLS
# no-tls
# 禁用 DTLS
# no-dtls
@@ -0,0 +1,45 @@
# /etc/turnserver.conf
# 监听所有接口
listening-ip=0.0.0.0
# 使用你的服务器公网IP
external-ip=43.153.3.146
# TURN 服务器端口
listening-port=3478
# 启用 TLS--TURNS(加密的 TURN
tls-listening-port=5349
# 中继端口范围
min-port=49152
max-port=65535
# 长期证书机制
lt-cred-mech
# TURN 服务器域名(如果有的话)
realm=turn.securityshare.xyz
# realm=43.153.3.146
# TURN 服务器证书和密钥(用于TLS)
cert=/etc/letsencrypt/live/turn.securityshare.xyz/fullchain.pem
pkey=/etc/letsencrypt/live/turn.securityshare.xyz/privkey.pem
# 用户名和密码(在生产环境中应使用更安全的方法)
user=secureUser:QWERTY!@#456
# 启用详细日志
verbose
# 允许回环地址
# allow-loopback-peers
# 设置最大带宽(字节/秒)
# max-bandwidth=0
# 禁用 TLS
# no-tls
# 禁用 DTLS
# no-dtls
+18
View File
@@ -0,0 +1,18 @@
module.exports = {
apps: [{
name: "signaling-server",
script: "./dist/server.js", // 指向编译后的文件
watch: false,
env: {
"NODE_ENV": "production",
"PORT": 3001
},
log_date_format: "YYYY-MM-DD HH:mm:ss",
error_file: "/var/log/signaling-server-error.log",
out_file: "/var/log/signaling-server-out.log",
max_memory_restart: "500M",
instances: 1,
exec_mode: "fork",
group: "ssl-cert" // 添加这行,指定进程运行的组
}]
}
Generated Executable
+2369
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"scripts": {
"dev": "cross-env NODE_ENV=development tsx watch src/server.ts",
"build": "rimraf dist && tsc",
"start": "cross-env NODE_ENV=production node dist/server.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"ioredis": "^5.4.1",
"socket.io": "^4.8.1"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.13.4",
"cross-env": "^7.0.3",
"rimraf": "^6.0.1",
"ts-node": "^10.9.2",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}
+31
View File
@@ -0,0 +1,31 @@
import dotenv from 'dotenv';
import path from 'path';
// 定义配置对象的类型
interface AppConfig {
PORT: number;
CORS_ORIGIN: string;
NODE_ENV: 'development' | 'production';
REDIS: {
HOST: string;
PORT: number;
};
}
// 根据环境加载对应的.env文件
dotenv.config({
path: process.env.NODE_ENV === 'production'
? path.resolve(process.cwd(), '.env.production.local')
: path.resolve(process.cwd(), '.env.development.local')
});
// 导出类型安全的配置对象
export const CONFIG: AppConfig = {
PORT: parseInt(process.env.PORT || '3001', 10),
CORS_ORIGIN: process.env.CORS_ORIGIN!,
NODE_ENV: (process.env.NODE_ENV as 'development' | 'production') || 'development',
REDIS: {
HOST: 'localhost',
PORT: 6379
}
};
+32
View File
@@ -0,0 +1,32 @@
import { CorsOptions } from 'cors';
import { CONFIG } from './env';
// 配置 CORS
export const corsOptions: CorsOptions = CONFIG.NODE_ENV === 'production'
? {
origin: CONFIG.CORS_ORIGIN,
methods: ['GET', 'POST', 'OPTIONS'],
credentials: true,
allowedHeaders: ['Content-Type', 'Authorization']
}
: {
origin: true, // 开发环境允许所有源
credentials: true,
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
};
// 配置 Socket.IO 的 CORS
export const corsWSOptions = CONFIG.NODE_ENV === 'production'
? {
origin: CONFIG.CORS_ORIGIN,// 允许的源,替换为你的Next.js应用的URL
methods: ['GET', 'POST'],
credentials: true
}
: {
// 开发环境下允许多个源
origin: [
CONFIG.CORS_ORIGIN,
/^http:\/\/192\.168\.\d+\.\d+:3000$/,// 匹配所有 192.168.x.x:3000 格式的局域网地址
],
methods: ['GET', 'POST'],
credentials: true
};
+123
View File
@@ -0,0 +1,123 @@
import { Router, RequestHandler } from 'express';
import { redis } from '../services/redis';
import * as roomService from '../services/room';
import { ReferrerTrack, LogMessage } from '../types/room';
const router = Router();
// 定义接口提高代码可读性和类型安全性
interface CreateRoomRequest {
roomId: string;
}
interface CheckRoomRequest {
roomId: string;
}
// 创建房间的路由处理函数
const createRoomHandler: RequestHandler<{}, any, CreateRoomRequest> = async (req, res) => {
const { roomId } = req.body;
if (!roomId) {
res.status(400).json({ error: 'Room ID is required' });
return;
}
try {
const exists = await roomService.isRoomExist(roomId);
const response = {
success: !exists,
message: exists ? 'roomId is already exists' : 'create room success'
};
if (!exists) {
await roomService.createRoom(roomId);
}
res.json(response);
} catch (error) {
console.error('Error checking room:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
// 获取房间的路由处理函数
const getRoomHandler: RequestHandler = async (req, res) => {
try {
const roomId = await roomService.getAvailableRoomId();
await roomService.createRoom(roomId);
res.json({ roomId });
} catch (error) {
console.error('Error getting room:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
// 检查房间的路由处理函数
const checkRoomHandler: RequestHandler<{}, any, CheckRoomRequest> = async (req, res) => {
const { roomId } = req.body;
if (!roomId) {
res.status(400).json({ error: 'Room ID is required' });
return;
}
try {
const exists = await roomService.isRoomExist(roomId);
res.json({ available: !exists });
} catch (error) {
console.error('Error checking room:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
// 设置跟踪的路由处理函数
const setTrackHandler: RequestHandler<{}, any, ReferrerTrack> = async (req, res) => {
if (req.method !== 'POST') {
res.status(405).json({ message: 'Method not allowed' });
return;
}
try {
const { ref, timestamp, path } = req.body;
// 按日期统计
const date = new Date(timestamp).toISOString().split('T')[0];
//"referrers:daily:2024-01-20" : { "producthunt": "5", "twitter": "3" }
const dailyKey = `referrers:daily:${date}`;
const thirtyDaysInSeconds = 30 * 24 * 60 * 60;
// 使用MULTI确保hincrby和expire的原子性
await redis.multi()
.hincrby(dailyKey, ref, 1) // \"referrers:daily:2024-01-20\" : { \"producthunt\": \"5\", \"twitter\": \"3\" }
.expire(dailyKey, thirtyDaysInSeconds) // 设置30天过期
.exec();
await redis.hincrby(`referrers:daily:${date}`, ref, 1);
//"referrers:sources" : ["producthunt", "twitter", ...] // 来源集合
await redis.sadd('referrers:sources', ref);
res.status(200).json({ success: true });
} catch (error) {
console.error('Track API Error:', error);
res.status(500).json({ success: false, error: 'Failed to track referrer' });
}
};
// 日志调试的路由处理函数
const logsDebugHandler: RequestHandler<{}, any, LogMessage> = async (req, res) => {
try {
const { message, timestamp } = req.body;
console.log(`logs----timestamp:${timestamp} message:${message}`);
res.status(200).json({ success: true });
} catch (error) {
console.error('Error checking room:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
// 注册路由
router.post('/api/creat_room', createRoomHandler);
router.get('/api/get_room', getRoomHandler);
router.post('/api/check_room', checkRoomHandler);
router.post('/api/set_track', setTrackHandler);
router.post('/api/logs_debug', logsDebugHandler);
export default router;
+29
View File
@@ -0,0 +1,29 @@
import express from 'express';//express: 用于创建一个简洁且灵活的Node.js web应用框架
import cors from 'cors';
import http from 'http';
import { Server } from 'socket.io';//实时通信库,基于WebSocket协议,实现双向通信
import { CONFIG } from './config/env';
import { corsOptions, corsWSOptions } from './config/server';
import { redis } from './services/redis';
import { cleanupData } from './utils/dataCleanup';
import apiRouter from './routes/api';
import { setupSocketHandlers } from './socket/handlers';
// 设置定时清理任务,间隔24H
const CLEANUP_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
setInterval(() => {
cleanupData(redis);
}, CLEANUP_INTERVAL);
const app = express();//创建一个Express应用
app.use(cors(corsOptions));// 添加 CORS 中间件
app.use(express.json());
app.use(apiRouter);
const server = http.createServer(app);
const io = new Server(server, { cors: corsWSOptions });
setupSocketHandlers(io);
server.listen(CONFIG.PORT, () => {
console.log(`Signaling server running in ${CONFIG.NODE_ENV} mode on port ${CONFIG.PORT}`);
});
+40
View File
@@ -0,0 +1,40 @@
import { redis } from './redis';
const RATE_LIMIT_PREFIX = 'ratelimit:join:';
const RATE_WINDOW = 5; // 5秒时间窗口
const RATE_LIMIT = 2; // 允许的最大请求次数
export async function checkRateLimit(ip: string): Promise<{
allowed: boolean;
remaining: number;
resetAfter: number;
}> {
const key = `${RATE_LIMIT_PREFIX}${ip}`;
const now = Date.now();
const windowStart = now - (RATE_WINDOW * 1000);
// 使用 Redis 的 MULTI 命令开启事务
const pipeline = redis.pipeline();
// 移除时间窗口之前的数据
pipeline.zremrangebyscore(key, 0, windowStart);
// 获取当前时间窗口内的请求次数
pipeline.zcard(key);
// 添加新的请求记录
pipeline.zadd(key, now, `${now}`);
// 设置过期时间
pipeline.expire(key, RATE_WINDOW);
const results = await pipeline.exec();
if (!results) {
throw new Error('Redis pipeline failed');
}
const requestCount = results[1][1] as number;
const allowed = requestCount <= RATE_LIMIT;
const remaining = Math.max(RATE_LIMIT - requestCount, 0);
const resetAfter = RATE_WINDOW - Math.floor((now - windowStart) / 1000);
return { allowed, remaining, resetAfter };
}
+25
View File
@@ -0,0 +1,25 @@
import { Redis } from 'ioredis';
import { CONFIG } from '../config/env';
// 房间前缀和过期时间(秒)
export const ROOM_PREFIX = 'room:';
export const SOCKET_PREFIX = 'socket:';
export const ROOM_EXPIRY = 3600 * 24; // 24 hours
// Redis 配置选项
const redisConfig = {
host: CONFIG.REDIS.HOST,
port: CONFIG.REDIS.PORT,
// Redis 持久化配置需要在 redis.conf 中设置,而不是在客户端
// appendonly: 'yes',// 启用 AOF 持久化
// save: '900 1 300 10',// 启用 RDB 快照
};
export const redis = new Redis(redisConfig);
// 可以在这里添加连接事件监听
redis.on('connect', () => {
console.log('Redis connected successfully');
});
redis.on('error', (err) => {
console.error('Redis connection error:', err);
});
+67
View File
@@ -0,0 +1,67 @@
import { redis, ROOM_PREFIX, SOCKET_PREFIX, ROOM_EXPIRY } from './redis';
// 生成随机房间号--4位数字
export async function generateRoomId(): Promise<string> {
return Math.floor(1000 + Math.random() * 9000).toString();
}
// 检查房间是否存在
export async function isRoomExist(roomId: string): Promise<boolean> {
return await redis.hexists(ROOM_PREFIX + roomId, 'created_at') === 1;//hset和hexists方法操作哈希,created_at是字段名
}
// 创建新房间
// (Hash)
// "room:1234" : {
// "created_at": "1705123456789"
// }
export async function createRoom(roomId: string): Promise<void> {
await redis.multi()
.hset(ROOM_PREFIX + roomId, 'created_at', Date.now())//设置hash,存储房间的创建时间;
.expire(ROOM_PREFIX + roomId, ROOM_EXPIRY)//设置过期时间
.exec();
}
// 删除房间
export async function deleteRoom(roomId: string): Promise<void> {
await redis.del(ROOM_PREFIX + roomId);
}
// 刷新房间过期时间
export async function refreshRoom(roomId: string, expiry: number = 0): Promise<void> {
const actualExpiry = expiry > 0 ? expiry : ROOM_EXPIRY;
console.log(`EXPIRY of roomId:${roomId} is ${actualExpiry}`);
await redis.expire(ROOM_PREFIX + roomId, actualExpiry);
}
// 获取可用房间号
export async function getAvailableRoomId(): Promise<string> {
let roomId: string;
do {
roomId = await generateRoomId();
} while (await isRoomExist(roomId));
return roomId;
}
// 将socket.id与房间号绑定
export async function bindSocketToRoom(socketId: string, roomId: string): Promise<void> {
await redis.multi()
//字符串,存储与该socket ID相关联的房间号,"socket:abcd1234" : "1234"
.set(SOCKET_PREFIX + socketId, roomId)
//添加集合,房间内的 Socket 列表 (Set),"room:1234:sockets" : ["socket1", "socket2", ...]
.sadd(ROOM_PREFIX + roomId + ':sockets', socketId)
.exec();
}
// 获取socket.id对应的房间号
export async function getRoomBySocketId(socketId: string): Promise<string | null> {
return await redis.get(SOCKET_PREFIX + socketId);
}
// 解绑socket.id与房间号
export async function unbindSocketFromRoom(socketId: string, roomId: string): Promise<void> {
await redis.multi()
.del(SOCKET_PREFIX + socketId)//解绑socket ID与房间号
.srem(ROOM_PREFIX + roomId + ':sockets', socketId)//从房间的集合中移除socket ID
.exec();
}
// 检查房间是否为空
export async function isRoomEmpty(roomId: string): Promise<boolean> {
const count = await redis.scard(ROOM_PREFIX + roomId + ':sockets');//返回集合中元素的数量
return count === 0;
}
// 检查房间连接数
export async function roomNumOfConnection(roomId: string): Promise<number> {
return await redis.scard(ROOM_PREFIX + roomId + ':sockets');
}
+125
View File
@@ -0,0 +1,125 @@
import { Server, Socket } from 'socket.io';
import * as roomService from '../services/room';
import { JoinData, SignalingData, InitiatorData, RecipientData } from '../types/socket';
import { checkRateLimit } from '../services/rateLimit';
// 房间管理:
// 使用 roomId 进行广播消息(socket.to(roomId).emit()
// 场景:新用户加入通知、房间状态更新等
// WebRTC 信令:
// 使用 peerId 进行点对点通信(socket.to(peerId).emit()
// 场景:offer、answer、ice-candidate 等 WebRTC 连接建立过程中的所有信令
export function setupSocketHandlers(io: Server): void {
io.on('connection', (socket: Socket) => {
console.log('New client connected:', socket.id);
socket.on('join', async (data: JoinData) => {
const { roomId } = data;
try {
// 获取客户端IP
const clientIp = socket.handshake.headers['x-forwarded-for'] ||
socket.handshake.address;
// 检查频率限制
const rateLimitCheck = await checkRateLimit(clientIp as string);
if (!rateLimitCheck.allowed) {
socket.emit('joinResponse', {
success: false,
message: `Rate limit exceeded. Please try again in ${rateLimitCheck.resetAfter} seconds. ` +
`You have ${rateLimitCheck.remaining} attempts remaining.`,
roomId: roomId
});
return;
}
const roomExist = await roomService.isRoomExist(roomId);
console.log(`room ${roomId} roomExist:${roomExist}`);
if (roomExist) {//房间存在
const existingRoomId = await roomService.getRoomBySocketId(socket.id);
if (!existingRoomId) {//socket.id不在房间里面 才允许新连接进入房间
socket.join(roomId);
console.log(`Client ${socket.id} joined room ${roomId}`);
await roomService.bindSocketToRoom(socket.id, roomId);
}
await roomService.refreshRoom(roomId);
// 通知用户加入成功
socket.emit('joinResponse', {
success: true,
message: 'Successfully joined room',
roomId: roomId
});
// 通知房间内所有其他用户有新成员加入
socket.to(roomId).emit('ready', {
peerId: socket.id
});
} else {
console.log(`room ${roomId} roomExist:${roomExist},Room does not exist branch`);
socket.emit('joinResponse', {
success: false,
message: 'Room does not exist',
roomId: roomId
});
}
} catch (error) {
console.error('Error joining room:', error);
socket.emit('joinResponse', {
success: false,
message: 'Server error while joining room',
error: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// 处理WebRTC信令--直接转发
// offer, answer, ice-candidate: 这些事件处理WebRTC连接的信令。它们负责转发客户端之间的连接请求和网络协商消息。
// offer: 当一个客户端发起连接请求时,会发送一个offer给服务器,服务器将其转发给相同房间中的其他客户端。
// answer: 被邀请的客户端接收到offer后,生成一个answer,通过服务器返回给发起连接的客户端。
// ice-candidate: 当WebRTC需要穿透NAT防火墙时,会生成ICE候选者,客户端通过服务器相互交换这些信息,帮助建立P2P连接。
socket.on('offer', (data: SignalingData) => {
socket.to(data.peerId).emit('offer', {
offer: data.offer,
from: data.from,
peerId: socket.id // 发送方的ID
});
});
socket.on('answer', (data: SignalingData) => {
socket.to(data.peerId).emit('answer', {
answer: data.answer,
from: data.from,
peerId: socket.id
});
});
socket.on('ice-candidate', (data: SignalingData) => {
socket.to(data.peerId).emit('ice-candidate', {
candidate: data.candidate,
from: data.from,
peerId: socket.id
});
});
// 处理发起方重新上线的通知--广播给房间内的其他用户
socket.on('initiator-online', (data: InitiatorData) => {
socket.to(data.roomId).emit('initiator-online', {
roomId: data.roomId
});
});
// 处理接收方的响应
socket.on('recipient-ready', (data: RecipientData) => {
socket.to(data.roomId).emit('recipient-ready', {
peerId: data.peerId
});
});
socket.on('disconnect', async () => {
console.log('Disconnected:', socket.id);
const roomId = await roomService.getRoomBySocketId(socket.id);
if (roomId) {
await roomService.unbindSocketFromRoom(socket.id, roomId);
if (await roomService.isRoomEmpty(roomId)) {
// await deleteRoom(roomId);
await roomService.refreshRoom(roomId, 3600);
console.log(`Room ${roomId} is empty and will deleted in 1 hour`);
}
}
});
});
}
+14
View File
@@ -0,0 +1,14 @@
export interface RoomInfo {
created_at: number;
}
export interface ReferrerTrack {
ref: string;
timestamp: number;
path: string;
}
export interface LogMessage {
message: string;
timestamp: number;
}
+37
View File
@@ -0,0 +1,37 @@
export interface JoinData {
roomId: string;
}
// 添加 WebRTC 相关类型定义
declare global {
interface RTCSessionDescriptionInit {
type: RTCSdpType;
sdp: string;
}
interface RTCIceCandidateInit {
candidate: string;
sdpMLineIndex?: number | null;
sdpMid?: string | null;
usernameFragment?: string | null;
}
type RTCSdpType = 'answer' | 'offer' | 'pranswer' | 'rollback';
}
export interface SignalingData {
peerId: string;
from?: string;
offer?: RTCSessionDescriptionInit;
answer?: RTCSessionDescriptionInit;
candidate?: RTCIceCandidateInit;
}
export interface InitiatorData {
roomId: string;
}
export interface RecipientData {
roomId: string;
peerId: string;
}
+26
View File
@@ -0,0 +1,26 @@
//定时任务处理器
import { Redis } from 'ioredis';
export const cleanupData = async (redis: Redis): Promise<void> => {
try {
// 1. 找出30天以前(过期)的每日统计数据
const today = new Date();
const thirtyDaysAgo = new Date(today.setDate(today.getDate() - 30));
// 获取所有 daily 统计 key
const dailyKeys = await redis.keys('referrers:daily:*');
for (const key of dailyKeys) {
const keyDate = key.split(':')[2];
if (new Date(keyDate) < thirtyDaysAgo) {
await redis.del(key);
} else {
// 为未过期的 key 重新设置过期时间
await redis.expire(key, 60 * 60 * 24 * 30); // 30天
}
}
console.log('Data cleanup completed successfully');
} catch (error) {
console.error('Error during data cleanup:', error);
}
};
+115
View File
@@ -0,0 +1,115 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"lib": ["ES2020"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
"rootDir": "./src", /* Specify the root folder within your source files. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
"baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
"paths": {
"@/*": ["src/*"]
}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
"resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}