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