Spaces:
Sleeping
Sleeping
Download server.js from MineRoma/AI_generation: direct link, hf CLI and curl.
- Browser
- Download file 19.8 kB
-
https://huggingface.co/spaces/MineRoma/AI_generation/resolve/main/server.js
- Command line
-
hf download hf://spaces/MineRoma/AI_generation/server.js
-
curl -L -o server.js https://huggingface.co/spaces/MineRoma/AI_generation/resolve/main/server.js
19.8 kB
| // ═══════════════════════════════════════════ | |
| // AnBiMessenger – E2EE Messenger Backend | |
| // Express 4 + Sharding + Security Hardened | |
| // ═══════════════════════════════════════════ | |
| import express from 'express'; | |
| import { fileURLToPath } from 'url'; | |
| import { dirname, join } from 'path'; | |
| import { readFileSync, existsSync } from 'fs'; | |
| import { createHmac, timingSafeEqual } from 'crypto'; | |
| const __filename = fileURLToPath(import.meta.url); | |
| const __dirname = dirname(__filename); | |
| const app = express(); | |
| const PORT = process.env.PORT || 7860; | |
| // ── Environment Variables ── | |
| const GITHUB_TOKEN = process.env.GITHUB_TOKEN || ''; | |
| const GIST_ID = process.env.GIST_ID || ''; | |
| const SHARD_PREFIX = 'messages_part_'; | |
| const SHARD_SIZE = 300; | |
| const MAX_PAYLOAD_SIZE = 500000; | |
| // ── Server-side secret for HMAC validation ── | |
| // Derived from GITHUB_TOKEN so it's unique per deployment but never exposed | |
| const SERVER_SECRET = GITHUB_TOKEN | |
| ? createHmac('sha256', 'anbi-messenger-v2').update(GITHUB_TOKEN).digest('hex') | |
| : 'dev-mode-secret-change-me'; | |
| // ── Middleware ── | |
| app.use(express.json({ limit: '10mb' })); | |
| // Security headers | |
| app.use((_req, res, next) => { | |
| res.setHeader('X-Content-Type-Options', 'nosniff'); | |
| res.setHeader('X-Frame-Options', 'DENY'); | |
| res.setHeader('X-XSS-Protection', '1; mode=block'); | |
| res.setHeader('Referrer-Policy', 'no-referrer'); | |
| next(); | |
| }); | |
| // Serve static files from dist | |
| const distPath = join(__dirname, 'dist'); | |
| if (existsSync(distPath)) { | |
| app.use(express.static(distPath)); | |
| } | |
| // ── In-Memory Cache ── | |
| let cachedMessages = []; | |
| let lastFetchTime = 0; | |
| let fetchEtag = ''; | |
| // ── Write Queue ── | |
| let writeQueue = []; | |
| let isFlushingQueue = false; | |
| // ── Rate Limiting (per IP) ── | |
| const rateLimitMap = new Map(); | |
| const RATE_WINDOW = 5000; | |
| const RATE_MAX = 15; | |
| function getClientIP(req) { | |
| return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || 'unknown'; | |
| } | |
| function checkRate(req) { | |
| const ip = getClientIP(req); | |
| const now = Date.now(); | |
| const entry = rateLimitMap.get(ip); | |
| if (!entry || now - entry.start > RATE_WINDOW) { | |
| rateLimitMap.set(ip, { start: now, count: 1 }); | |
| return true; | |
| } | |
| entry.count++; | |
| return entry.count <= RATE_MAX; | |
| } | |
| // Cleanup rate limit map | |
| setInterval(() => { | |
| const now = Date.now(); | |
| for (const [ip, entry] of rateLimitMap.entries()) { | |
| if (now - entry.start > RATE_WINDOW * 2) rateLimitMap.delete(ip); | |
| } | |
| }, 30000); | |
| // ═══════════════════════════════════════════ | |
| // AUTH: HMAC Token Validation | |
| // ═══════════════════════════════════════════ | |
| // | |
| // Client sends: Authorization: HMAC <token> | |
| // Token = HMAC-SHA256(SERVER_SECRET, senderHash + ":" + timestamp) | |
| // timestamp must be within 5 minutes window | |
| // | |
| // This ensures: | |
| // 1. Only clients who know the seed phrase can compute senderHash | |
| // 2. The token is time-limited (replay window = 5 min) | |
| // 3. SERVER_SECRET is never exposed to client — but both sides | |
| // derive the same HMAC differently: | |
| // - Server uses GITHUB_TOKEN-derived secret | |
| // - Client sends its own HMAC; server validates structure | |
| // | |
| // ACTUALLY: Since we can't share SERVER_SECRET with the client, | |
| // we use a different approach: | |
| // Client sends: X-Auth-Hash: <senderHash> | |
| // X-Auth-Token: HMAC-SHA256(seedPhrase, senderHash + ":" + timestamp) | |
| // X-Auth-Time: <timestamp> | |
| // Server cannot verify the seed phrase (doesn't know it), BUT: | |
| // - Server stores the token from the FIRST request as the "session token" for that senderHash | |
| // - Subsequent requests must use the SAME token derivation | |
| // - This proves the client has the same seed phrase across requests | |
| // | |
| // SIMPLER SECURE APPROACH: | |
| // Since all messages are E2E encrypted and the server is just a relay, | |
| // the real security is: | |
| // 1. IDOR Prevention: Server strictly checks senderHash ownership for edit/delete | |
| // 2. Data Access: Require a room token (hash of seed phrase) to fetch messages | |
| // 3. The room token proves you know the seed phrase without revealing it | |
| // Room token registry: maps roomToken -> Set of senderHashes that have posted | |
| const roomRegistry = new Map(); // roomToken -> { hashes: Set, lastSeen: number } | |
| function computeRoomToken(seedPhrase) { | |
| // Client computes: SHA256(seedPhrase) -> first 16 chars | |
| // We can't compute this server-side. Client sends it. | |
| // But we validate it's consistent. | |
| return null; | |
| } | |
| /** | |
| * Validate auth headers. | |
| * Required headers: | |
| * X-Room-Token: SHA256(seedPhrase).substring(0, 16) — proves knowledge of seed | |
| * X-Sender-Hash: the sender's hash (for write operations) | |
| * | |
| * For GET /api/messages: only X-Room-Token required | |
| * For POST /api/messages: X-Room-Token + X-Sender-Hash required | |
| */ | |
| function validateAuth(req, requireSender = false) { | |
| const roomToken = req.headers['x-room-token']; | |
| const senderHash = req.headers['x-sender-hash']; | |
| if (!roomToken || typeof roomToken !== 'string' || roomToken.length < 8 || roomToken.length > 64) { | |
| return { valid: false, error: 'Missing or invalid X-Room-Token header', status: 401 }; | |
| } | |
| // Sanitize | |
| if (!/^[a-f0-9]+$/i.test(roomToken)) { | |
| return { valid: false, error: 'Invalid room token format', status: 401 }; | |
| } | |
| if (requireSender) { | |
| if (!senderHash || typeof senderHash !== 'string' || senderHash.length < 4 || senderHash.length > 16) { | |
| return { valid: false, error: 'Missing or invalid X-Sender-Hash header', status: 401 }; | |
| } | |
| if (!/^[a-f0-9]+$/i.test(senderHash)) { | |
| return { valid: false, error: 'Invalid sender hash format', status: 401 }; | |
| } | |
| } | |
| return { valid: true, roomToken, senderHash }; | |
| } | |
| // ═══════════════════════════════════════════ | |
| // SHARDING | |
| // ═══════════════════════════════════════════ | |
| function shardName(i) { | |
| return `${SHARD_PREFIX}${i}.json`; | |
| } | |
| function splitIntoShards(msgs) { | |
| const shards = {}; | |
| for (let i = 0; i < msgs.length; i += SHARD_SIZE) { | |
| const idx = Math.floor(i / SHARD_SIZE) + 1; | |
| shards[shardName(idx)] = msgs.slice(i, i + SHARD_SIZE); | |
| } | |
| if (Object.keys(shards).length === 0) { | |
| shards[shardName(1)] = []; | |
| } | |
| return shards; | |
| } | |
| // ═══════════════════════════════════════════ | |
| // GIST API | |
| // ═══════════════════════════════════════════ | |
| async function fetchFromGist() { | |
| if (!GITHUB_TOKEN || !GIST_ID) return; | |
| try { | |
| const headers = { | |
| 'Authorization': `Bearer ${GITHUB_TOKEN}`, | |
| 'Accept': 'application/vnd.github+json', | |
| 'X-GitHub-Api-Version': '2022-11-28', | |
| }; | |
| if (fetchEtag) headers['If-None-Match'] = fetchEtag; | |
| const resp = await fetch(`https://api.github.com/gists/${GIST_ID}`, { headers }); | |
| if (resp.status === 304) { | |
| lastFetchTime = Date.now(); | |
| return; | |
| } | |
| if (!resp.ok) { | |
| console.error(`[Gist Fetch] HTTP ${resp.status}: ${resp.statusText}`); | |
| return; | |
| } | |
| const etag = resp.headers.get('etag'); | |
| if (etag) fetchEtag = etag; | |
| const gist = await resp.json(); | |
| const all = []; | |
| // Try sharded files first | |
| const shardFiles = Object.keys(gist.files || {}) | |
| .filter(n => n.startsWith(SHARD_PREFIX)) | |
| .sort((a, b) => { | |
| const na = parseInt(a.replace(SHARD_PREFIX, '').replace('.json', '')); | |
| const nb = parseInt(b.replace(SHARD_PREFIX, '').replace('.json', '')); | |
| return na - nb; | |
| }); | |
| if (shardFiles.length > 0) { | |
| for (const fn of shardFiles) { | |
| try { | |
| const content = gist.files[fn]?.content; | |
| if (content) { | |
| const parsed = JSON.parse(content); | |
| if (Array.isArray(parsed)) all.push(...parsed); | |
| } | |
| } catch (e) { | |
| console.error(`[Gist] Parse error in ${fn}:`, e.message); | |
| } | |
| } | |
| } else { | |
| // Legacy single-file fallback | |
| try { | |
| const legacy = gist.files?.['messages.json']?.content; | |
| if (legacy) { | |
| const parsed = JSON.parse(legacy); | |
| if (Array.isArray(parsed)) all.push(...parsed); | |
| } | |
| } catch (e) { | |
| console.error('[Gist] Parse error in messages.json:', e.message); | |
| } | |
| } | |
| cachedMessages = all; | |
| lastFetchTime = Date.now(); | |
| console.log(`[Gist Fetch] OK – ${all.length} msgs (${shardFiles.length || 1} shard(s))`); | |
| } catch (e) { | |
| console.error('[Gist Fetch] Error:', e.message); | |
| } | |
| } | |
| async function flushQueue() { | |
| if (isFlushingQueue || writeQueue.length === 0 || !GITHUB_TOKEN || !GIST_ID) return; | |
| isFlushingQueue = true; | |
| const batch = [...writeQueue]; | |
| writeQueue = []; | |
| try { | |
| let messages = [...cachedMessages]; | |
| for (const op of batch) { | |
| if (op.action === 'add') { | |
| if (!messages.some(m => m.id === op.data.id)) { | |
| messages.push(op.data); | |
| } | |
| } else if (op.action === 'edit') { | |
| const idx = messages.findIndex(m => m.id === op.data.id); | |
| // CRITICAL: Verify senderHash matches the STORED message | |
| if (idx !== -1 && messages[idx].senderHash === op.data.senderHash) { | |
| messages[idx] = { ...messages[idx], payload: op.data.payload, lastModified: op.data.lastModified }; | |
| } | |
| } else if (op.action === 'delete') { | |
| const idx = messages.findIndex(m => m.id === op.data.id); | |
| // CRITICAL: Verify senderHash matches the STORED message | |
| if (idx !== -1 && messages[idx].senderHash === op.data.senderHash) { | |
| messages.splice(idx, 1); | |
| } | |
| } | |
| } | |
| const shards = splitIntoShards(messages); | |
| const files = {}; | |
| for (const [fn, content] of Object.entries(shards)) { | |
| files[fn] = { content: JSON.stringify(content) }; | |
| } | |
| files['messages.json'] = { content: '[]' }; | |
| const resp = await fetch(`https://api.github.com/gists/${GIST_ID}`, { | |
| method: 'PATCH', | |
| headers: { | |
| 'Authorization': `Bearer ${GITHUB_TOKEN}`, | |
| 'Content-Type': 'application/json', | |
| 'Accept': 'application/vnd.github+json', | |
| 'X-GitHub-Api-Version': '2022-11-28', | |
| }, | |
| body: JSON.stringify({ files }), | |
| }); | |
| if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); | |
| const etag = resp.headers.get('etag'); | |
| if (etag) fetchEtag = etag; | |
| cachedMessages = messages; | |
| lastFetchTime = Date.now(); | |
| console.log(`[Gist Flush] OK – ${batch.length} op(s), total: ${messages.length}`); | |
| } catch (e) { | |
| writeQueue = [...batch, ...writeQueue]; | |
| console.error('[Gist Flush] Error:', e.message); | |
| } finally { | |
| isFlushingQueue = false; | |
| } | |
| } | |
| // ═══════════════════════════════════════════ | |
| // API ROUTES | |
| // ═══════════════════════════════════════════ | |
| // ── GET /api/messages ── | |
| // Requires X-Room-Token header (SHA256 of seed phrase, first 16 chars) | |
| // Only returns messages — they're encrypted, so useless without the seed | |
| // But without the room token, you get nothing | |
| app.get('/api/messages', (req, res) => { | |
| if (!checkRate(req)) return res.status(429).json({ error: 'Rate limited' }); | |
| const auth = validateAuth(req, false); | |
| if (!auth.valid) { | |
| return res.status(auth.status).json({ error: auth.error }); | |
| } | |
| // Filter messages: only return messages that belong to this room | |
| // Each message has a roomToken stored with it | |
| const roomMessages = cachedMessages.filter(m => m.roomToken === auth.roomToken); | |
| // Return only the fields the client needs — strip roomToken (internal) | |
| const sanitized = roomMessages.map(m => ({ | |
| id: m.id, | |
| senderHash: m.senderHash, | |
| payload: m.payload, | |
| timestamp: m.timestamp, | |
| lastModified: m.lastModified, | |
| })); | |
| res.json(sanitized); | |
| }); | |
| // ── POST /api/messages ── | |
| // Requires X-Room-Token + X-Sender-Hash headers | |
| app.post('/api/messages', (req, res) => { | |
| if (!checkRate(req)) return res.status(429).json({ error: 'Rate limited' }); | |
| const auth = validateAuth(req, true); | |
| if (!auth.valid) { | |
| return res.status(auth.status).json({ error: auth.error }); | |
| } | |
| const { action } = req.body; | |
| if (action === 'add') { | |
| const { id, payload, timestamp } = req.body; | |
| if (!id || !payload || !timestamp) { | |
| return res.status(400).json({ error: 'Missing fields: id, payload, timestamp' }); | |
| } | |
| if (typeof payload !== 'string' || payload.length > MAX_PAYLOAD_SIZE) { | |
| return res.status(413).json({ error: 'Payload too large or invalid' }); | |
| } | |
| const msg = { | |
| id: String(id).substring(0, 64), | |
| senderHash: String(auth.senderHash).substring(0, 16), | |
| roomToken: String(auth.roomToken).substring(0, 16), | |
| payload, | |
| timestamp: Number(timestamp), | |
| }; | |
| // Duplicate check | |
| if (cachedMessages.some(m => m.id === msg.id)) { | |
| return res.json({ ok: true, queued: writeQueue.length, duplicate: true }); | |
| } | |
| if (writeQueue.some(op => op.action === 'add' && op.data.id === msg.id)) { | |
| return res.json({ ok: true, queued: writeQueue.length, duplicate: true }); | |
| } | |
| writeQueue.push({ action: 'add', data: msg }); | |
| cachedMessages.push(msg); | |
| return res.json({ ok: true, queued: writeQueue.length }); | |
| } else if (action === 'edit') { | |
| const { id, payload, lastModified } = req.body; | |
| if (!id || !payload || !lastModified) { | |
| return res.status(400).json({ error: 'Missing fields for edit' }); | |
| } | |
| if (typeof payload !== 'string' || payload.length > MAX_PAYLOAD_SIZE) { | |
| return res.status(413).json({ error: 'Payload too large' }); | |
| } | |
| // ── IDOR PROTECTION: Find message and verify ownership ── | |
| const existingMsg = cachedMessages.find(m => m.id === id); | |
| if (!existingMsg) { | |
| return res.status(404).json({ error: 'Message not found' }); | |
| } | |
| if (existingMsg.senderHash !== auth.senderHash) { | |
| console.warn(`[SECURITY] IDOR attempt: ${auth.senderHash} tried to edit message owned by ${existingMsg.senderHash} (IP: ${getClientIP(req)})`); | |
| return res.status(403).json({ error: 'Forbidden: you can only edit your own messages' }); | |
| } | |
| if (existingMsg.roomToken !== auth.roomToken) { | |
| console.warn(`[SECURITY] Cross-room attempt: ${auth.senderHash} tried to edit message in different room (IP: ${getClientIP(req)})`); | |
| return res.status(403).json({ error: 'Forbidden: message belongs to a different room' }); | |
| } | |
| writeQueue.push({ | |
| action: 'edit', | |
| data: { id, senderHash: auth.senderHash, payload, lastModified: Number(lastModified) } | |
| }); | |
| const idx = cachedMessages.findIndex(m => m.id === id); | |
| if (idx !== -1) { | |
| cachedMessages[idx] = { ...cachedMessages[idx], payload, lastModified: Number(lastModified) }; | |
| } | |
| return res.json({ ok: true, queued: writeQueue.length }); | |
| } else if (action === 'delete') { | |
| const { id } = req.body; | |
| if (!id) { | |
| return res.status(400).json({ error: 'Missing message id' }); | |
| } | |
| // ── IDOR PROTECTION: Find message and verify ownership ── | |
| const existingMsg = cachedMessages.find(m => m.id === id); | |
| if (!existingMsg) { | |
| return res.status(404).json({ error: 'Message not found' }); | |
| } | |
| if (existingMsg.senderHash !== auth.senderHash) { | |
| console.warn(`[SECURITY] IDOR attempt: ${auth.senderHash} tried to delete message owned by ${existingMsg.senderHash} (IP: ${getClientIP(req)})`); | |
| return res.status(403).json({ error: 'Forbidden: you can only delete your own messages' }); | |
| } | |
| if (existingMsg.roomToken !== auth.roomToken) { | |
| console.warn(`[SECURITY] Cross-room attempt: ${auth.senderHash} tried to delete message in different room (IP: ${getClientIP(req)})`); | |
| return res.status(403).json({ error: 'Forbidden: message belongs to a different room' }); | |
| } | |
| writeQueue.push({ action: 'delete', data: { id, senderHash: auth.senderHash } }); | |
| const idx = cachedMessages.findIndex(m => m.id === id); | |
| if (idx !== -1) { | |
| cachedMessages.splice(idx, 1); | |
| } | |
| return res.json({ ok: true, queued: writeQueue.length }); | |
| } else { | |
| return res.status(400).json({ error: 'Invalid action. Use: add, edit, delete' }); | |
| } | |
| }); | |
| app.get('/api/status', (_req, res) => { | |
| res.json({ | |
| ok: true, | |
| cached: cachedMessages.length, | |
| queued: writeQueue.length, | |
| lastFetch: lastFetchTime ? new Date(lastFetchTime).toISOString() : null, | |
| gistConfigured: !!(GITHUB_TOKEN && GIST_ID), | |
| }); | |
| }); | |
| // SPA fallback | |
| app.get('*', (_req, res) => { | |
| const indexPath = join(__dirname, 'dist', 'index.html'); | |
| if (existsSync(indexPath)) { | |
| res.sendFile(indexPath); | |
| } else { | |
| res.status(200).send(` | |
| <!DOCTYPE html> | |
| <html> | |
| <head><title>AnBiMessenger</title></head> | |
| <body style="background:#0e1621;color:#fff;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"> | |
| <div style="text-align:center"> | |
| <h1>💎 AnBiMessenger</h1> | |
| <p>Build not found. Run <code>npm run build</code> first.</p> | |
| <p style="color:#6b7d8e;font-size:14px">API is running on port ${PORT}</p> | |
| </div> | |
| </body> | |
| </html> | |
| `); | |
| } | |
| }); | |
| // ═══════════════════════════════════════════ | |
| // START | |
| // ═══════════════════════════════════════════ | |
| // Initial fetch | |
| fetchFromGist().then(() => { | |
| console.log('[Init] Initial Gist fetch complete'); | |
| }).catch(e => { | |
| console.error('[Init] Initial fetch failed:', e.message); | |
| }); | |
| // Background timers | |
| setInterval(fetchFromGist, 4000); | |
| setInterval(flushQueue, 2000); | |
| app.listen(PORT, '0.0.0.0', () => { | |
| console.log(''); | |
| console.log(' ╔═══════════════════════════════════════════════╗'); | |
| console.log(' ║ 💎 AnBiMessenger Server Started (Hardened) ║'); | |
| console.log(' ╠═══════════════════════════════════════════════╣'); | |
| console.log(` ║ Port: ${PORT} ║`); | |
| console.log(` ║ GITHUB_TOKEN: ${GITHUB_TOKEN ? '✅ Set' : '❌ Not set'} ║`); | |
| console.log(` ║ GIST_ID: ${GIST_ID ? '✅ Set' : '❌ Not set'} ║`); | |
| console.log(` ║ Shard Size: ${SHARD_SIZE} msgs/shard ║`); | |
| console.log(` ║ Auth: Room Token + IDOR checks ║`); | |
| console.log(` ║ Express: v4 (stable) ║`); | |
| console.log(' ╚═══════════════════════════════════════════════╝'); | |
| console.log(''); | |
| if (!GITHUB_TOKEN || !GIST_ID) { | |
| console.log(' ⚠️ Set GITHUB_TOKEN and GIST_ID environment variables'); | |
| console.log(' for persistent message storage via GitHub Gist.'); | |
| console.log(''); | |
| } | |
| }); | |