add relay server

This commit is contained in:
diyaa 2026-07-26 15:58:35 +02:00
commit 215c17f8ee
7 changed files with 318 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
# macOS
.DS_Store
# Node
relay-server/node_modules/
relay-server/.env
# Swift build
.build/
build/
*.o
*.d

View File

@ -0,0 +1,3 @@
PORT=3000
MAX_FILE_SIZE_MB=25
UPLOADS_DIR=/data/files

2
relay-server/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
.env

20
relay-server/Dockerfile Normal file
View 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"]

View 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
View 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
View 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}`);
});