208 lines
6.2 KiB
JavaScript
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}`);
|
|
});
|