add relay server
This commit is contained in:
commit
215c17f8ee
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Node
|
||||
relay-server/node_modules/
|
||||
relay-server/.env
|
||||
|
||||
# Swift build
|
||||
.build/
|
||||
build/
|
||||
*.o
|
||||
*.d
|
||||
3
relay-server/.env.example
Normal file
3
relay-server/.env.example
Normal file
@ -0,0 +1,3 @@
|
||||
PORT=3000
|
||||
MAX_FILE_SIZE_MB=25
|
||||
UPLOADS_DIR=/data/files
|
||||
2
relay-server/.gitignore
vendored
Normal file
2
relay-server/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.env
|
||||
20
relay-server/Dockerfile
Normal file
20
relay-server/Dockerfile
Normal file
@ -0,0 +1,20 @@
|
||||
# المرحلة الأولى: تنزيل الـ dependencies فقط
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# المرحلة الثانية: الـ image النهائي
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY src/ ./src/
|
||||
COPY package.json ./
|
||||
|
||||
RUN mkdir -p /data/files
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "src/index.js"]
|
||||
25
relay-server/docker-compose.yml
Normal file
25
relay-server/docker-compose.yml
Normal file
@ -0,0 +1,25 @@
|
||||
services:
|
||||
fchati-relay:
|
||||
build: .
|
||||
image: fchati-relay:latest
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
volumes:
|
||||
- fchati-files:/data/files
|
||||
networks:
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.fchati.rule=Host(`fchati.diyaa.de`)"
|
||||
- "traefik.http.routers.fchati.entrypoints=websecure"
|
||||
# غيّر letsencrypt إذا كان اسم certresolver عندك مختلف
|
||||
- "traefik.http.routers.fchati.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.fchati.loadbalancer.server.port=3000"
|
||||
|
||||
volumes:
|
||||
fchati-files:
|
||||
|
||||
networks:
|
||||
traefik-net:
|
||||
# غيّر هذا ليطابق اسم network Traefik عندك
|
||||
external: true
|
||||
13
relay-server/package.json
Normal file
13
relay-server/package.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "fchati-relay",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"ws": "^8.17.1"
|
||||
}
|
||||
}
|
||||
243
relay-server/src/index.js
Normal file
243
relay-server/src/index.js
Normal file
@ -0,0 +1,243 @@
|
||||
const express = require('express');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const { createServer } = require('http');
|
||||
const { randomBytes, randomUUID } = require('crypto');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// ─── إعداد ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const MAX_FILE_BYTES = parseInt(process.env.MAX_FILE_SIZE_MB ?? '25') * 1024 * 1024;
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? '/data/files';
|
||||
const PAIRING_TTL_MS = 5 * 60 * 1000; // 5 دقائق
|
||||
|
||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// ─── رفع الملفات ─────────────────────────────────────────────────────────────
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: UPLOADS_DIR,
|
||||
filename: (_req, _file, cb) => cb(null, randomUUID()),
|
||||
});
|
||||
const upload = multer({ storage, limits: { fileSize: MAX_FILE_BYTES } });
|
||||
|
||||
// ─── الذاكرة الداخلية ─────────────────────────────────────────────────────────
|
||||
|
||||
// code → { code, creatorID, creatorName, creatorToken, joinerID, joinerName, joinerToken, expiresAt }
|
||||
const pairingSessions = new Map();
|
||||
|
||||
// peerID → { ws, name }
|
||||
const connections = new Map();
|
||||
|
||||
// fileID → { diskPath, originalName, size }
|
||||
const fileRegistry = new Map();
|
||||
|
||||
// ─── أدوات مساعدة ─────────────────────────────────────────────────────────────
|
||||
|
||||
function generateCode() {
|
||||
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const bytes = randomBytes(6);
|
||||
let suffix = '';
|
||||
for (let i = 0; i < 6; i++) suffix += alphabet[bytes[i] % alphabet.length];
|
||||
return 'FCHT-' + suffix;
|
||||
}
|
||||
|
||||
function generateToken() {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
function sessionByToken(token) {
|
||||
for (const s of pairingSessions.values()) {
|
||||
if (s.creatorToken === token || s.joinerToken === token) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function peerIDFromToken(session, token) {
|
||||
return session.creatorToken === token ? session.creatorID : session.joinerID;
|
||||
}
|
||||
|
||||
function cleanExpiredSessions() {
|
||||
const now = Date.now();
|
||||
for (const [code, s] of pairingSessions) {
|
||||
if (s.expiresAt < now) pairingSessions.delete(code);
|
||||
}
|
||||
}
|
||||
setInterval(cleanExpiredSessions, 60_000);
|
||||
|
||||
function send(ws, obj) {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
// ─── Middleware مصادقة HTTP ───────────────────────────────────────────────────
|
||||
|
||||
function requireToken(req, res, next) {
|
||||
const auth = req.headers.authorization ?? '';
|
||||
if (!auth.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const session = sessionByToken(auth.slice(7));
|
||||
if (!session) return res.status(401).json({ error: 'Invalid token' });
|
||||
req.peerID = peerIDFromToken(session, auth.slice(7));
|
||||
next();
|
||||
}
|
||||
|
||||
// ─── مسارات الـ Pairing ───────────────────────────────────────────────────────
|
||||
|
||||
app.post('/pairing/create', (req, res) => {
|
||||
const name = req.body?.displayName?.trim();
|
||||
if (!name) return res.status(400).json({ error: 'displayName مطلوب' });
|
||||
|
||||
let code, tries = 0;
|
||||
do {
|
||||
code = generateCode();
|
||||
if (++tries > 200) return res.status(503).json({ error: 'حاول مجدداً' });
|
||||
} while (pairingSessions.has(code));
|
||||
|
||||
const session = {
|
||||
code,
|
||||
creatorID: randomUUID(),
|
||||
creatorName: name,
|
||||
creatorToken: generateToken(),
|
||||
joinerID: null,
|
||||
joinerName: null,
|
||||
joinerToken: null,
|
||||
expiresAt: Date.now() + PAIRING_TTL_MS,
|
||||
};
|
||||
pairingSessions.set(code, session);
|
||||
|
||||
res.json({
|
||||
code,
|
||||
token: session.creatorToken,
|
||||
peerID: session.creatorID,
|
||||
expiresAt: new Date(session.expiresAt).toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/pairing/join', (req, res) => {
|
||||
const code = req.body?.code?.trim().toUpperCase();
|
||||
const name = req.body?.displayName?.trim();
|
||||
if (!code || !name) return res.status(400).json({ error: 'code و displayName مطلوبان' });
|
||||
|
||||
const session = pairingSessions.get(code);
|
||||
if (!session || Date.now() > session.expiresAt) {
|
||||
pairingSessions.delete(code);
|
||||
return res.status(404).json({ error: 'الكود غير موجود أو انتهت صلاحيته' });
|
||||
}
|
||||
if (session.joinerID) return res.status(409).json({ error: 'الكود استُخدم مسبقاً' });
|
||||
|
||||
session.joinerID = randomUUID();
|
||||
session.joinerName = name;
|
||||
session.joinerToken = generateToken();
|
||||
|
||||
res.json({
|
||||
token: session.joinerToken,
|
||||
peerID: session.joinerID,
|
||||
peer: {
|
||||
id: session.creatorID,
|
||||
displayName: session.creatorName,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ─── مسارات الملفات ───────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/files', requireToken, upload.single('file'), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'لم يُرسَل أي ملف' });
|
||||
|
||||
const fileID = req.file.filename;
|
||||
fileRegistry.set(fileID, {
|
||||
diskPath: req.file.path,
|
||||
originalName: req.file.originalname,
|
||||
size: req.file.size,
|
||||
});
|
||||
|
||||
res.json({ id: fileID, name: req.file.originalname, size: req.file.size });
|
||||
});
|
||||
|
||||
app.get('/files/:id', requireToken, (req, res) => {
|
||||
const meta = fileRegistry.get(req.params.id);
|
||||
if (!meta) return res.status(404).json({ error: 'الملف غير موجود' });
|
||||
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(meta.originalName)}"`);
|
||||
res.sendFile(meta.diskPath);
|
||||
});
|
||||
|
||||
// ─── فحص الصحة ───────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/health', (_req, res) => res.json({ ok: true, connections: connections.size }));
|
||||
|
||||
// ─── WebSocket ────────────────────────────────────────────────────────────────
|
||||
|
||||
const server = createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
let peerID = null;
|
||||
let peerName = null;
|
||||
let authed = false;
|
||||
|
||||
const authTimeout = setTimeout(() => {
|
||||
if (!authed) ws.close(1008, 'Auth timeout');
|
||||
}, 10_000);
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(raw.toString()); }
|
||||
catch { return ws.close(1003, 'JSON غير صالح'); }
|
||||
|
||||
if (!authed) {
|
||||
if (msg.type !== 'auth' || !msg.token) {
|
||||
return ws.close(1008, 'أرسل { type: "auth", token: "..." } أولاً');
|
||||
}
|
||||
const session = sessionByToken(msg.token);
|
||||
if (!session) return ws.close(1008, 'Token غير صالح');
|
||||
|
||||
clearTimeout(authTimeout);
|
||||
authed = true;
|
||||
peerID = peerIDFromToken(session, msg.token);
|
||||
peerName = session.creatorToken === msg.token ? session.creatorName : session.joinerName;
|
||||
connections.set(peerID, { ws, name: peerName });
|
||||
|
||||
send(ws, { type: 'auth.ok', peerID });
|
||||
console.log(`[ws] متصل: ${peerName} (${peerID})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'chat.message') {
|
||||
const target = connections.get(msg.to);
|
||||
if (target?.ws.readyState === 1) {
|
||||
send(target.ws, { ...msg, from: peerID, fromName: peerName });
|
||||
send(ws, { type: 'delivered', id: msg.id });
|
||||
} else {
|
||||
send(ws, { type: 'not_delivered', id: msg.id, reason: 'peer_offline' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'ping') {
|
||||
send(ws, { type: 'pong' });
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (peerID) {
|
||||
connections.delete(peerID);
|
||||
console.log(`[ws] انقطع: ${peerName} (${peerID})`);
|
||||
}
|
||||
clearTimeout(authTimeout);
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error(`[ws] خطأ:`, err.message);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── تشغيل ────────────────────────────────────────────────────────────────────
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Fchati Relay يعمل على المنفذ ${PORT}`);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user