Files
2026-03-30 23:22:55 -05:00

208 lines
6.2 KiB
JavaScript

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}`);
});