From 215c17f8ee1414be0d6ccc85b6ab3a67eb45e025 Mon Sep 17 00:00:00 2001 From: diyaa Date: Sun, 26 Jul 2026 15:58:35 +0200 Subject: [PATCH] add relay server --- .gitignore | 12 ++ relay-server/.env.example | 3 + relay-server/.gitignore | 2 + relay-server/Dockerfile | 20 +++ relay-server/docker-compose.yml | 25 ++++ relay-server/package.json | 13 ++ relay-server/src/index.js | 243 ++++++++++++++++++++++++++++++++ 7 files changed, 318 insertions(+) create mode 100644 .gitignore create mode 100644 relay-server/.env.example create mode 100644 relay-server/.gitignore create mode 100644 relay-server/Dockerfile create mode 100644 relay-server/docker-compose.yml create mode 100644 relay-server/package.json create mode 100644 relay-server/src/index.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62afc69 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# macOS +.DS_Store + +# Node +relay-server/node_modules/ +relay-server/.env + +# Swift build +.build/ +build/ +*.o +*.d diff --git a/relay-server/.env.example b/relay-server/.env.example new file mode 100644 index 0000000..9e90010 --- /dev/null +++ b/relay-server/.env.example @@ -0,0 +1,3 @@ +PORT=3000 +MAX_FILE_SIZE_MB=25 +UPLOADS_DIR=/data/files diff --git a/relay-server/.gitignore b/relay-server/.gitignore new file mode 100644 index 0000000..713d500 --- /dev/null +++ b/relay-server/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.env diff --git a/relay-server/Dockerfile b/relay-server/Dockerfile new file mode 100644 index 0000000..fdf88f3 --- /dev/null +++ b/relay-server/Dockerfile @@ -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"] diff --git a/relay-server/docker-compose.yml b/relay-server/docker-compose.yml new file mode 100644 index 0000000..2c03bae --- /dev/null +++ b/relay-server/docker-compose.yml @@ -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 diff --git a/relay-server/package.json b/relay-server/package.json new file mode 100644 index 0000000..8af3561 --- /dev/null +++ b/relay-server/package.json @@ -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" + } +} diff --git a/relay-server/src/index.js b/relay-server/src/index.js new file mode 100644 index 0000000..1d21375 --- /dev/null +++ b/relay-server/src/index.js @@ -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}`); +});