// Litz Server — Minimal Chat Backend // Node.js, zero external dependencies const http = require("http"); const crypto = require("crypto"); const fs = require("fs"); const path = require("path"); const nodemailer = require("nodemailer"); // ─── Config ─────────────────────────────────────────────────────────────────── const superConfig = JSON.parse(fs.readFileSync(path.join(__dirname, "super.json"), "utf8")); const mailer = nodemailer.createTransport({ host: superConfig.mail.host, port: superConfig.mail.port, // 465 secure: superConfig.mail.secure, // true → SSL/TLS (port 465) auth: { user: superConfig.mail.user, pass: superConfig.mail.pass, }, }); const PORT = process.env.PORT || 3000; const DB_DIR = path.join(__dirname, "data"); // ─── DB ─────────────────────────────────────────────────────────────────────── function dbPath(name) { return path.join(DB_DIR, name + ".json"); } function loadDB(name) { const p = dbPath(name); if (!fs.existsSync(p)) return {}; try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return {}; } } // Atomic write: write to .tmp then rename — prevents corruption on crash function saveDB(name, data) { const p = dbPath(name); const tmp = p + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8"); fs.renameSync(tmp, p); } if (!fs.existsSync(DB_DIR)) fs.mkdirSync(DB_DIR, { recursive: true }); // ─── State ──────────────────────────────────────────────────────────────────── const db = { accounts: loadDB("accounts"), // { [id]: Account } tokens: loadDB("tokens"), // { [token]: { id, expires } } codes: loadDB("codes"), // { [mail]: { code, expires, attempts } } pool: loadDB("pool"), // { [id]: [ Message ] } id = recipient }; // ─── Rate Limiter ───────────────────────────────────────────────────────────── // In-memory, resets on restart — intentional (lightweight) const rateLimiter = new Map(); // key → { count, resetAt } function rateLimit(key, maxReqs, windowMs) { const now = Date.now(); let entry = rateLimiter.get(key); if (!entry || now > entry.resetAt) { entry = { count: 0, resetAt: now + windowMs }; rateLimiter.set(key, entry); } entry.count++; return entry.count > maxReqs; // true = blocked } // Clean stale rate limit entries every 5 min setInterval(() => { const now = Date.now(); for (const [k, v] of rateLimiter) { if (now > v.resetAt) rateLimiter.delete(k); } }, 5 * 60 * 1000); // ─── Helpers ────────────────────────────────────────────────────────────────── function uid() { return crypto.randomBytes(8).toString("hex"); } function token() { return crypto.randomBytes(32).toString("hex"); } function code6() { // 6-digit numeric code — crypto-safe const buf = crypto.randomBytes(4); return String(buf.readUInt32BE(0) % 1000000).padStart(6, "0"); } // Constant-time string compare — prevents timing attacks function safeEqual(a, b) { if (typeof a !== "string" || typeof b !== "string") return false; if (a.length !== b.length) { // Still run comparison to avoid length-based timing leak crypto.timingSafeEqual(Buffer.alloc(1), Buffer.alloc(1)); return false; } return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); } function findAccountByMail(mail) { return Object.values(db.accounts).find(a => a.mail === mail) || null; } function findAccountById(id) { return db.accounts[id] || null; } // Resolve token → account. Returns account or null. function authToken(tokenStr) { if (!tokenStr) return null; const entry = db.tokens[tokenStr]; if (!entry) return null; if (Date.now() > entry.expires) { delete db.tokens[tokenStr]; saveDB("tokens", db.tokens); return null; } return findAccountById(entry.id) || null; } const TOKEN_TTL = 30 * 24 * 60 * 60 * 1000; // 30 days const CODE_TTL = 10 * 60 * 1000; // 10 minutes const MESSAGE_MAX_LEN = 2000; const POOL_MAX_PER_USER = 500; // ─── HTTP Helpers ───────────────────────────────────────────────────────────── function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; req.on("data", chunk => { size += chunk.length; if (size > 64 * 1024) { // 64KB max body reject(new Error("Body too large")); req.destroy(); return; } chunks.push(chunk); }); req.on("end", () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } catch { resolve({}); } }); req.on("error", reject); }); } function send(res, status, data) { const body = JSON.stringify(data); res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), "X-Content-Type-Options": "nosniff", }); res.end(body); } function ok(res, data) { send(res, 200, { ok: true, ...data }); } function err(res, msg, status = 400) { send(res, status, { ok: false, error: msg }); } // ─── Mail ───────────────────────────────────────────────────────────────────── async function sendMail(to, code) { await mailer.sendMail({ from: superConfig.mail.from, to, subject: "Litz giriş kodu", text: `Giriş kodunuz: ${code}\n\n10 dakika geçerlidir. Bu kodu kimseyle paylaşmayın.`, html: `

Giriş kodunuz:

${code}

10 dakika geçerlidir. Bu kodu kimseyle paylaşmayın.

`, }); } // ─── Routes ─────────────────────────────────────────────────────────────────── const routes = {}; function route(method, path, handler) { routes[method + " " + path] = handler; } // ── /qls/register ───────────────────────────────────────────────────────────── route("POST", "/qls/register", async (req, res, ip) => { if (rateLimit("reg:" + ip, 5, 60 * 60 * 1000)) // 5/hr per IP return err(res, "Too many registrations", 429); const { mail, name } = await readBody(req); if (!mail || !name) return err(res, "mail and name required"); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(mail)) return err(res, "Invalid mail"); if (name.length < 2 || name.length > 32) return err(res, "Name must be 2–32 chars"); if (findAccountByMail(mail)) return err(res, "Mail already registered"); const id = uid(); db.accounts[id] = { id, name, mail, createdAt: Date.now() }; saveDB("accounts", db.accounts); ok(res, { id, name }); }); // ── /qls/login ──────────────────────────────────────────────────────────────── route("POST", "/qls/login", async (req, res, ip) => { if (rateLimit("login:" + ip, 10, 60 * 1000)) // 10/min per IP return err(res, "Too many requests", 429); const { mail } = await readBody(req); if (!mail) return err(res, "mail required"); const account = findAccountByMail(mail); // Always respond ok — mail enumeration önlemi if (account) { const c = code6(); // Eski kodu sil, yeni kod yaz — her login isteği kodu sıfırlar db.codes[mail] = { code: c, expires: Date.now() + CODE_TTL, used: false }; saveDB("codes", db.codes); try { await sendMail(mail, c); } catch (e) { // Mail gönderilemezse kodu temizle, sunucu hatası döndür delete db.codes[mail]; saveDB("codes", db.codes); console.error("[MAIL ERROR]", e.message); return err(res, "Mail gönderilemedi", 500); } } ok(res, { sent: true }); }); // ── /qls/verify ─────────────────────────────────────────────────────────────── // Tek atış: yanlış kod → hemen silinir, yeni login gerekir route("POST", "/qls/verify", async (req, res, ip) => { if (rateLimit("verify:" + ip, 20, 60 * 1000)) // 20/min per IP return err(res, "Too many requests", 429); const { mail, key } = await readBody(req); if (!mail || !key) return err(res, "mail and key required"); const entry = db.codes[mail]; if (!entry) return err(res, "Bekleyen kod yok"); if (Date.now() > entry.expires) { delete db.codes[mail]; saveDB("codes", db.codes); return err(res, "Kod süresi doldu"); } // Doğru mu kontrol et — her halükarda kodu sil (tek atış) const correct = safeEqual(entry.code, String(key).trim()); delete db.codes[mail]; saveDB("codes", db.codes); if (!correct) return err(res, "Yanlış kod"); // Kod doğru — token oluştur const account = findAccountByMail(mail); const tok = token(); db.tokens[tok] = { id: account.id, expires: Date.now() + TOKEN_TTL }; saveDB("tokens", db.tokens); ok(res, { token: tok, id: account.id, name: account.name }); }); // ── /qls/logout ─────────────────────────────────────────────────────────────── route("POST", "/qls/logout", async (req, res) => { const { token: tok } = await readBody(req); if (!tok) return err(res, "token required"); if (db.tokens[tok]) { delete db.tokens[tok]; saveDB("tokens", db.tokens); } ok(res, {}); }); // ── /qls/status ─────────────────────────────────────────────────────────────── route("POST", "/qls/status", async (req, res) => { const { token: tok } = await readBody(req); const account = authToken(tok); ok(res, { valid: !!account, ...(account ? { id: account.id, name: account.name } : {}) }); }); // ── /qls/users ──────────────────────────────────────────────────────────────── // List users (authenticated) — needed for "who can I message?" route("POST", "/qls/users", async (req, res) => { const { token: tok, search } = await readBody(req); if (!authToken(tok)) return err(res, "Unauthorized", 401); let users = Object.values(db.accounts).map(a => ({ id: a.id, name: a.name })); if (search && search.trim()) { const q = search.trim().toLowerCase(); users = users.filter(u => u.name.toLowerCase().includes(q)); } ok(res, { users: users.slice(0, 50) }); }); // ── /qma/send ───────────────────────────────────────────────────────────────── route("POST", "/qma/send", async (req, res, ip) => { if (rateLimit("send:" + ip, 60, 60 * 1000)) // 60 msg/min per IP return err(res, "Too many messages", 429); const { token: tok, to, message } = await readBody(req); const sender = authToken(tok); if (!sender) return err(res, "Unauthorized", 401); if (!to || !message) return err(res, "to and message required"); if (typeof message !== "string" || message.trim().length === 0) return err(res, "Empty message"); if (message.length > MESSAGE_MAX_LEN) return err(res, `Message too long (max ${MESSAGE_MAX_LEN})`); if (!findAccountById(to)) return err(res, "Recipient not found"); if (to === sender.id) return err(res, "Cannot message yourself"); if (!db.pool[to]) db.pool[to] = []; if (db.pool[to].length >= POOL_MAX_PER_USER) return err(res, "Recipient inbox full"); const msg = { id: uid(), from: sender.id, msg: message.trim(), at: Date.now(), }; db.pool[to].push(msg); saveDB("pool", db.pool); ok(res, { id: msg.id, at: msg.at }); }); // ── /qma/refresh ────────────────────────────────────────────────────────────── // Returns pending messages for the authenticated user, then clears them route("POST", "/qma/refresh", async (req, res) => { const { token: tok } = await readBody(req); const account = authToken(tok); if (!account) return err(res, "Unauthorized", 401); const messages = db.pool[account.id] || []; db.pool[account.id] = []; saveDB("pool", db.pool); ok(res, { messages }); }); // ─── Router ─────────────────────────────────────────────────────────────────── const server = http.createServer(async (req, res) => { const ip = req.headers["x-forwarded-for"]?.split(",")[0].trim() || req.socket.remoteAddress || "unknown"; const url = req.url.split("?")[0]; // ignore query strings const key = req.method + " " + url; // Global IP rate limit — catches hammering before any route logic if (rateLimit("ip:" + ip, 200, 60 * 1000)) return err(res, "Too many requests", 429); const handler = routes[key]; if (!handler) return err(res, "Not found", 404); try { await handler(req, res, ip); } catch (e) { console.error("[ERROR]", e); err(res, "Internal error", 500); } }); server.listen(PORT, () => { console.log(`Litz running on :${PORT}`); });