const http = require('http'); const fs = require('fs'); const path = require('path'); const { URL } = require('url'); const HOST = process.env.HOST || '127.0.0.1'; const PORT = Number(process.env.PORT) || 8000; const ROOT = __dirname; const DATA_TXT_PATH = path.join(ROOT, 'data.txt'); const DATA_JSON_PATH = path.join(ROOT, 'data.json'); const MIME_TYPES = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.txt': 'text/plain; charset=utf-8' }; function sendJson(res, statusCode, payload) { res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(payload, null, 2)); } function sendText(res, statusCode, text, contentType = 'text/plain; charset=utf-8') { res.writeHead(statusCode, { 'Content-Type': contentType, 'Cache-Control': 'no-store' }); res.end(text); } function readBody(req) { return new Promise((resolve, reject) => { let body = ''; req.on('data', chunk => { body += chunk; if (body.length > 1_000_000) { reject(new Error('Request body too large.')); req.destroy(); } }); req.on('end', () => resolve(body)); req.on('error', reject); }); } function normalizeItem(item, fallbackId) { return { id: String(item.id || fallbackId || Date.now()), level: String(item.level || 'new').trim() || 'new', position: String(item.position || '').trim(), title: String(item.title || '').trim(), url: String(item.url || '').trim() }; } function parseTxtData(txt) { return txt .split(/\r?\n/) .map(line => line.trim()) .filter(Boolean) .map((line, index) => { const parts = line.split('|').map(part => part.trim()); return normalizeItem({ level: parts[0], position: parts[1], title: parts[2], url: parts[3] }, `legacy-${index + 1}`); }); } function serializeTxtData(items) { return items .map(item => [item.level, item.position, item.title, item.url].join('|')) .join('\n') + '\n'; } function loadItems() { if (fs.existsSync(DATA_JSON_PATH)) { const raw = fs.readFileSync(DATA_JSON_PATH, 'utf8'); const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed.map((item, index) => normalizeItem(item, `json-${index + 1}`)); } if (!fs.existsSync(DATA_TXT_PATH)) return []; const txt = fs.readFileSync(DATA_TXT_PATH, 'utf8'); const items = parseTxtData(txt); saveItems(items); return items; } function saveItems(items) { const normalized = items.map((item, index) => normalizeItem(item, `item-${index + 1}`)); fs.writeFileSync(DATA_JSON_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8'); fs.writeFileSync(DATA_TXT_PATH, serializeTxtData(normalized), 'utf8'); return normalized; } function validateItem(item) { if (!item.title) return 'Title is required.'; if (!item.position) return 'Position is required.'; if (!/^\d+$/.test(String(item.position))) return 'Position must be numeric.'; return null; } function getStaticPath(requestPath) { const cleanPath = requestPath === '/' ? '/index.html' : requestPath; const safePath = path.normalize(cleanPath).replace(/^(\.\.[/\\])+/, '').replace(/^[/\\]+/, ''); return path.join(ROOT, safePath); } function serveStatic(reqPath, res) { const filePath = getStaticPath(reqPath); if (!filePath.startsWith(ROOT)) { sendText(res, 403, 'Forbidden'); return; } fs.readFile(filePath, (err, content) => { if (err) { const fallback404 = path.join(ROOT, '404.html'); fs.readFile(fallback404, (fallbackErr, fallbackContent) => { if (fallbackErr) { sendText(res, 404, 'Not Found'); return; } sendText(res, 404, fallbackContent, 'text/html; charset=utf-8'); }); return; } const ext = path.extname(filePath).toLowerCase(); sendText(res, 200, content, MIME_TYPES[ext] || 'application/octet-stream'); }); } function sortItems(items) { return [...items].sort((a, b) => Number(a.position) - Number(b.position)); } loadItems(); const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const pathname = url.pathname; if (pathname === '/api/levels' && req.method === 'GET') { return sendJson(res, 200, { items: sortItems(loadItems()) }); } if (pathname === '/api/levels' && req.method === 'POST') { try { const body = await readBody(req); const payload = normalizeItem(JSON.parse(body), `item-${Date.now()}`); const error = validateItem(payload); if (error) return sendJson(res, 400, { error }); const items = loadItems(); items.push(payload); return sendJson(res, 201, { item: payload, items: sortItems(saveItems(items)) }); } catch (error) { return sendJson(res, 400, { error: 'Invalid JSON request body.' }); } } if (pathname.startsWith('/api/levels/') && (req.method === 'PUT' || req.method === 'DELETE')) { const id = decodeURIComponent(pathname.slice('/api/levels/'.length)); const items = loadItems(); const index = items.findIndex(item => item.id === id); if (index === -1) { return sendJson(res, 404, { error: 'Level not found.' }); } if (req.method === 'DELETE') { items.splice(index, 1); return sendJson(res, 200, { items: sortItems(saveItems(items)) }); } try { const body = await readBody(req); const payload = normalizeItem({ ...items[index], ...JSON.parse(body), id }, id); const error = validateItem(payload); if (error) return sendJson(res, 400, { error }); items[index] = payload; return sendJson(res, 200, { item: payload, items: sortItems(saveItems(items)) }); } catch (error) { return sendJson(res, 400, { error: 'Invalid JSON request body.' }); } } if (pathname === '/api/health') { return sendJson(res, 200, { ok: true }); } return serveStatic(pathname, res); }); server.listen(PORT, HOST, () => { console.log(`GD FEDL server running at http://localhost:${PORT}`); });