Files
gd-list/server/server.js
T

2172 lines
71 KiB
JavaScript

const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { URL } = require('url');
const appRoot = path.resolve(__dirname, '..');
const dataPath = path.join(__dirname, 'data.txt');
const legacyDataPath = path.join(appRoot, 'data.txt');
const runsPath = path.join(__dirname, 'runs.json');
const usersPath = path.join(__dirname, 'users.json');
const sessionsPath = path.join(__dirname, 'sessions.json');
const userDataPath = path.join(__dirname, 'userdata.json');
const resetTokensPath = path.join(__dirname, 'reset_tokens.json');
const postsPath = path.join(__dirname, 'posts.json');
const bugReportsPath = path.join(__dirname, 'bugreports.json');
const messagesPath = path.join(__dirname, 'messages.json');
const port = Number(process.env.PORT) || 8090;
const host = process.env.HOST || '127.0.0.1';
const BASE = '/fedl';
const adminPassword = String(process.env.ADMIN_PASSWORD || 'test');
const clients = new Set();
const contentTypes = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon'
};
function parseData(text) {
return text
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean)
.map(line => {
const parts = line.split('|').map(part => part.trim());
return {
level: parts[0] || 'Unknown',
position: parts[1] || '',
title: parts[2] || 'Untitled',
url: parts[3] || ''
};
});
}
function safeReadJsonFile(filePath, fallbackValue, label) {
try {
const raw = fs.readFileSync(filePath, 'utf8');
if (!String(raw || '').trim()) {
return fallbackValue;
}
return JSON.parse(raw);
} catch (error) {
console.error(`[FEDL] Failed to read ${label}: ${error.message}`);
return fallbackValue;
}
}
function readDataText() {
const serverCopyExists = fs.existsSync(dataPath);
const serverCopy = serverCopyExists ? fs.readFileSync(dataPath, 'utf8') : '';
if (String(serverCopy || '').trim()) {
return serverCopy;
}
if (fs.existsSync(legacyDataPath)) {
return fs.readFileSync(legacyDataPath, 'utf8');
}
return serverCopy;
}
function writeDataText(text) {
const normalized = `${String(text || '').trim()}\n`;
fs.writeFileSync(dataPath, normalized, 'utf8');
if (legacyDataPath !== dataPath && fs.existsSync(legacyDataPath)) {
fs.writeFileSync(legacyDataPath, normalized, 'utf8');
}
}
function setCors(res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, HEAD, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
function sendJson(res, statusCode, payload) {
setCors(res);
res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store'
});
res.end(JSON.stringify(payload));
}
function sendEvent(eventName, data) {
const message = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
for (const client of clients) {
client.write(message);
}
}
function isAuthorized(req) {
if (!adminPassword) return true;
const authHeader = String(req.headers.authorization || '');
if (!authHeader.startsWith('Basic ')) return false;
try {
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8');
const separatorIndex = decoded.indexOf(':');
const suppliedPassword = separatorIndex === -1 ? '' : decoded.slice(separatorIndex + 1);
return suppliedPassword === adminPassword;
} catch (error) {
return false;
}
}
function requireAdmin(req, res) {
if (isAuthorized(req)) return true;
setCors(res);
res.setHeader('WWW-Authenticate', 'Basic realm="FEDL Admin"');
res.writeHead(401, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Authentication required');
return false;
}
function ensureRunsFile() {
if (!fs.existsSync(runsPath)) {
fs.writeFileSync(runsPath, '[]\n', 'utf8');
}
}
function readRuns() {
ensureRunsFile();
const parsed = safeReadJsonFile(runsPath, [], 'runs file');
return Array.isArray(parsed) ? parsed : [];
}
function writeRuns(runs) {
fs.writeFileSync(runsPath, `${JSON.stringify(runs, null, 2)}\n`, 'utf8');
}
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function ensureUsersFile() {
if (!fs.existsSync(usersPath)) {
fs.writeFileSync(usersPath, '[]\n', 'utf8');
}
}
function ensureSessionsFile() {
if (!fs.existsSync(sessionsPath)) {
fs.writeFileSync(sessionsPath, '{}\n', 'utf8');
}
}
function ensureUserDataFile() {
if (!fs.existsSync(userDataPath)) {
fs.writeFileSync(userDataPath, '{}\n', 'utf8');
}
}
function ensureResetTokensFile() {
if (!fs.existsSync(resetTokensPath)) {
fs.writeFileSync(resetTokensPath, '{}\n', 'utf8');
}
}
function readUsers() {
ensureUsersFile();
const parsed = safeReadJsonFile(usersPath, [], 'users file');
return Array.isArray(parsed) ? parsed : [];
}
function writeUsers(users) {
fs.writeFileSync(usersPath, `${JSON.stringify(users, null, 2)}\n`, 'utf8');
}
function readSessionsRaw() {
ensureSessionsFile();
const parsed = safeReadJsonFile(sessionsPath, {}, 'sessions file');
return parsed && typeof parsed === 'object' ? parsed : {};
}
function writeSessions(sessions) {
fs.writeFileSync(sessionsPath, `${JSON.stringify(sessions, null, 2)}\n`, 'utf8');
}
function cleanSessions(sessions) {
const now = Date.now();
const out = {};
Object.keys(sessions).forEach(token => {
const s = sessions[token];
if (s && s.expiresAt && new Date(s.expiresAt).getTime() > now) {
out[token] = s;
}
});
return out;
}
function readSessions() {
return cleanSessions(readSessionsRaw());
}
function createSession(userId, username) {
const sessions = readSessions();
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
sessions[token] = { userId, username, expiresAt };
writeSessions(sessions);
return token;
}
function findSession(token) {
if (!token) {
return null;
}
const sessions = readSessions();
const s = sessions[token];
if (!s || new Date(s.expiresAt).getTime() <= Date.now()) {
return null;
}
return s;
}
function deleteSession(token) {
const sessions = readSessionsRaw();
delete sessions[token];
writeSessions(cleanSessions(sessions));
}
function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
return { salt, hash };
}
function verifyPassword(password, salt, hashHex) {
try {
const h = crypto.scryptSync(password, salt, 64).toString('hex');
const a = Buffer.from(h, 'hex');
const b = Buffer.from(hashHex, 'hex');
if (a.length !== b.length) {
return false;
}
return crypto.timingSafeEqual(a, b);
} catch (error) {
return false;
}
}
function normalizeUsername(u) {
return String(u || '').trim().toLowerCase();
}
function usernameOk(u) {
return /^[a-z0-9_]{3,24}$/.test(u);
}
function normalizeEmail(email) {
return String(email || '').trim().toLowerCase();
}
function emailOk(email) {
return EMAIL_RE.test(String(email || '').trim());
}
function hashToken(token) {
return crypto.createHash('sha256').update(String(token || '')).digest('hex');
}
function getBearerToken(req) {
const h = String(req.headers.authorization || '');
const m = h.match(/^Bearer\s+(\S+)/i);
return m ? m[1] : '';
}
function readUserDataMap() {
ensureUserDataFile();
const parsed = safeReadJsonFile(userDataPath, {}, 'user data file');
return parsed && typeof parsed === 'object' ? parsed : {};
}
function writeUserDataMap(map) {
fs.writeFileSync(userDataPath, `${JSON.stringify(map, null, 2)}\n`, 'utf8');
}
function readResetTokensRaw() {
ensureResetTokensFile();
const parsed = safeReadJsonFile(resetTokensPath, {}, 'reset tokens file');
return parsed && typeof parsed === 'object' ? parsed : {};
}
function cleanResetTokens(tokens) {
const now = Date.now();
const out = {};
Object.keys(tokens).forEach(key => {
const row = tokens[key];
if (row && row.expiresAt && new Date(row.expiresAt).getTime() > now) {
out[key] = row;
}
});
return out;
}
function readResetTokens() {
return cleanResetTokens(readResetTokensRaw());
}
function writeResetTokens(tokens) {
fs.writeFileSync(resetTokensPath, `${JSON.stringify(tokens, null, 2)}\n`, 'utf8');
}
function revokeUserSessions(userId) {
const sessions = readSessionsRaw();
Object.keys(sessions).forEach(token => {
const row = sessions[token];
if (row && row.userId === userId) {
delete sessions[token];
}
});
writeSessions(cleanSessions(sessions));
}
function removeResetTokensForUser(userId) {
const tokens = readResetTokensRaw();
Object.keys(tokens).forEach(key => {
const row = tokens[key];
if (row && row.userId === userId) {
delete tokens[key];
}
});
writeResetTokens(cleanResetTokens(tokens));
}
function getSessionFromRequest(req) {
return findSession(getBearerToken(req));
}
function getAppBaseUrl(req) {
const explicit = String(process.env.APP_BASE_URL || '').trim().replace(/\/+$/, '');
if (explicit) {
return explicit;
}
const proto = String(req.headers['x-forwarded-proto'] || '').trim() || 'http';
const hostHeader = String(req.headers['x-forwarded-host'] || req.headers.host || '').trim();
return `${proto}://${hostHeader}${BASE}`;
}
function getResetPasswordUrl(req, token) {
return `${getAppBaseUrl(req)}/reset-password.html?token=${encodeURIComponent(token)}`;
}
function mailConfigured() {
return !!(
String(process.env.SMTP_HOST || '').trim() &&
String(process.env.SMTP_FROM || '').trim()
);
}
function workerEmailConfigured() {
return !!String(process.env.CF_WORKER_URL || '').trim();
}
async function sendMailViaWorker({ to, subject, text }) {
const workerUrl = String(process.env.CF_WORKER_URL || '').trim();
const res = await fetch(`${workerUrl}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to, subject, text })
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Worker email failed: ${err}`);
}
return res.json();
}
function createLineReader(socket) {
let buffer = '';
const queue = [];
const waiters = [];
function flush() {
while (waiters.length && queue.length) {
waiters.shift()(queue.shift());
}
}
socket.on('data', chunk => {
buffer += chunk.toString('utf8');
let idx = buffer.indexOf('\r\n');
while (idx !== -1) {
queue.push(buffer.slice(0, idx));
buffer = buffer.slice(idx + 2);
idx = buffer.indexOf('\r\n');
}
flush();
});
return {
nextLine() {
if (queue.length) {
return Promise.resolve(queue.shift());
}
return new Promise(resolve => {
waiters.push(resolve);
});
}
};
}
async function readSmtpResponse(reader) {
const lines = [];
while (true) {
const line = await reader.nextLine();
lines.push(line);
if (!/^\d{3}-/.test(line)) {
return {
code: Number(String(line || '').slice(0, 3)),
lines
};
}
}
}
function smtpCommand(socket, reader, command, expectedCodes) {
return new Promise((resolve, reject) => {
socket.write(`${command}\r\n`, async error => {
if (error) {
reject(error);
return;
}
try {
const response = await readSmtpResponse(reader);
if (!expectedCodes.includes(response.code)) {
reject(new Error(`SMTP ${response.code}: ${response.lines.join(' | ')}`));
return;
}
resolve(response);
} catch (err) {
reject(err);
}
});
});
}
function buildMailMessage({ from, to, subject, text }) {
const messageId = `<${crypto.randomBytes(12).toString('hex')}@fedl.site>`;
const normalizedText = String(text || '').replace(/\r?\n/g, '\r\n').replace(/^\./gm, '..');
return [
`From: ${from}`,
`To: ${to}`,
`Subject: ${subject}`,
`Date: ${new Date().toUTCString()}`,
`Message-ID: ${messageId}`,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=utf-8',
'Content-Transfer-Encoding: 8bit',
'',
normalizedText,
'.'
].join('\r\n');
}
async function sendMail({ to, subject, text }) {
if (!mailConfigured() && !workerEmailConfigured()) {
throw new Error('Email delivery is not configured. Set SMTP_* vars or CF_WORKER_URL.');
}
if (!mailConfigured()) {
return sendMailViaWorker({ to, subject, text });
}
const tls = require('tls');
const net = require('net');
const hostName = String(process.env.SMTP_HOST || '').trim();
const portNumber = Number(process.env.SMTP_PORT) || 465;
const secure = String(process.env.SMTP_SECURE || 'true').trim().toLowerCase() !== 'false';
const smtpUser = String(process.env.SMTP_USER || '').trim();
const smtpPass = String(process.env.SMTP_PASS || '');
const from = String(process.env.SMTP_FROM || '').trim();
const ehloName = String(process.env.SMTP_EHLO_NAME || 'fedl.site').trim();
const socket = secure
? tls.connect({ host: hostName, port: portNumber, servername: hostName })
: require('net').connect({ host: hostName, port: portNumber });
await new Promise((resolve, reject) => {
socket.once('connect', resolve);
socket.once('error', reject);
});
const reader = createLineReader(socket);
try {
const greeting = await readSmtpResponse(reader);
if (greeting.code !== 220) {
throw new Error(`SMTP ${greeting.code}: ${greeting.lines.join(' | ')}`);
}
await smtpCommand(socket, reader, `EHLO ${ehloName}`, [250]);
if (smtpUser || smtpPass) {
await smtpCommand(socket, reader, 'AUTH LOGIN', [334]);
await smtpCommand(socket, reader, Buffer.from(smtpUser).toString('base64'), [334]);
await smtpCommand(socket, reader, Buffer.from(smtpPass).toString('base64'), [235]);
}
await smtpCommand(socket, reader, `MAIL FROM:<${from}>`, [250]);
await smtpCommand(socket, reader, `RCPT TO:<${to}>`, [250, 251]);
await smtpCommand(socket, reader, 'DATA', [354]);
await smtpCommand(socket, reader, buildMailMessage({ from, to, subject, text }), [250]);
await smtpCommand(socket, reader, 'QUIT', [221]);
} finally {
socket.end();
}
}
async function sendPasswordResetEmail(req, user) {
if (!user || !user.email) {
throw new Error('This account does not have an email address yet.');
}
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = hashToken(token);
const resetTokens = readResetTokensRaw();
const expiresAt = new Date(Date.now() + PASSWORD_RESET_TTL_MS).toISOString();
resetTokens[tokenHash] = {
userId: user.id,
email: user.email,
username: user.username,
expiresAt,
createdAt: new Date().toISOString()
};
writeResetTokens(cleanResetTokens(resetTokens));
const resetUrl = getResetPasswordUrl(req, token);
await sendMail({
to: user.email,
subject: 'Reset your FEDL password',
text: [
`Hi ${user.username},`,
'',
'We received a request to reset your FEDL password.',
'',
`Reset your password here: ${resetUrl}`,
'',
'This link expires in 1 hour. If you did not request a reset, you can ignore this email.'
].join('\n')
});
return { expiresAt, resetUrl };
}
function ensurePostsFile() {
if (!fs.existsSync(postsPath)) {
fs.writeFileSync(postsPath, '[]\n', 'utf8');
}
}
function readPosts() {
ensurePostsFile();
const parsed = safeReadJsonFile(postsPath, [], 'posts file');
return Array.isArray(parsed) ? parsed : [];
}
function writePosts(posts) {
fs.writeFileSync(postsPath, `${JSON.stringify(posts, null, 2)}\n`, 'utf8');
}
function ensureBugReportsFile() {
if (!fs.existsSync(bugReportsPath)) {
fs.writeFileSync(bugReportsPath, '[]\n', 'utf8');
}
}
function readBugReports() {
ensureBugReportsFile();
const parsed = safeReadJsonFile(bugReportsPath, [], 'bug reports file');
return Array.isArray(parsed) ? parsed : [];
}
function writeBugReports(reports) {
fs.writeFileSync(bugReportsPath, `${JSON.stringify(reports, null, 2)}\n`, 'utf8');
}
function ensureMessagesFile() {
if (!fs.existsSync(messagesPath)) {
fs.writeFileSync(messagesPath, '[]\n', 'utf8');
}
}
function readMessages() {
ensureMessagesFile();
const parsed = safeReadJsonFile(messagesPath, [], 'messages file');
return Array.isArray(parsed) ? parsed : [];
}
function safeWatch(filePath, eventName) {
try {
if (!fs.existsSync(filePath)) {
return null;
}
return fs.watch(filePath, { persistent: true }, () => {
sendEvent(eventName, { updatedAt: new Date().toISOString() });
});
} catch (error) {
console.error(`[FEDL] Could not watch ${filePath}: ${error.message}`);
return null;
}
}
function writeMessages(messages) {
fs.writeFileSync(messagesPath, `${JSON.stringify(messages, null, 2)}\n`, 'utf8');
}
function normalizePost(post) {
return {
id: String(post.id || ''),
authorId: String(post.authorId || ''),
authorName: String(post.authorName || ''),
level: String(post.level || '').slice(0, 200),
content: String(post.content || '').slice(0, 4000),
timestamp: String(post.timestamp || new Date().toISOString()),
likes: Array.isArray(post.likes) ? post.likes : [],
comments: Array.isArray(post.comments) ? post.comments : []
};
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function fetchWithRetry(url, options, retries = 3) {
let attempt = 0;
while (true) {
const res = await fetch(url, options);
if (res.ok) return res;
if (attempt >= retries || ![429, 503].includes(res.status)) return res;
const retryAfter = Number(res.headers.get('retry-after'));
const delayMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 2000 * (attempt + 1);
await sleep(delayMs);
attempt += 1;
}
}
function parseLinkHeader(header) {
const links = {};
if (!header) return links;
header.split(',').forEach(part => {
const match = part.match(/<([^>]+)>\s*;\s*rel=?"?([^";]+)"?/);
if (match) {
links[match[2]] = match[1];
}
});
return links;
}
function getAredlAuthHeaders() {
const headers = {};
const accessToken = String(process.env.AREDL_ACCESS_TOKEN || '').trim();
const apiKey = String(process.env.AREDL_API_KEY || '').trim();
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
} else if (apiKey) {
headers['api-key'] = apiKey;
}
return headers;
}
function normalizeImportedRun(payload, source) {
return normalizeRun({
playerName: String(payload.playerName || '').trim(),
levelTitle: String(payload.levelTitle || '').trim(),
videoUrl: String(payload.videoUrl || '').trim(),
percent: String(payload.percent || '100').trim() || '100',
rawFootageUrl: String(payload.rawFootageUrl || '').trim(),
notes: String(payload.notes || `Imported from ${source}`).trim(),
status: 'approved',
reviewedBy: `${source} import`,
reviewNotes: String(payload.reviewNotes || `Imported from ${source}`).trim()
});
}
function appendImportedRuns(importedRuns, source, options) {
const validRunNote = options && String(options.validRunNote || '').trim();
const currentRuns = readRuns();
const seen = new Set(currentRuns.map(run => String(run.videoUrl || '').trim().toLowerCase()).filter(Boolean));
const newRuns = [];
importedRuns.forEach(run => {
const videoUrl = String(run.videoUrl || '').trim();
if (!videoUrl) return;
const normalizedUrl = videoUrl.toLowerCase();
if (seen.has(normalizedUrl)) return;
seen.add(normalizedUrl);
const payload = validRunNote ? Object.assign({}, run, { notes: validRunNote, reviewNotes: validRunNote }) : run;
newRuns.push(normalizeImportedRun(payload, source));
});
if (newRuns.length) {
writeRuns(newRuns.concat(currentRuns));
sendEvent('runs-update', { updatedAt: new Date().toISOString() });
}
return {
added: newRuns.length,
skipped: importedRuns.length - newRuns.length,
total: importedRuns.length
};
}
function getLinkHeader(res) {
return res.headers.get('link') || res.headers.get('Link') || res.headers.get('links') || '';
}
async function fetchPointercrateRecordsStartingAt(startUrl, maxPages = 25) {
const results = [];
let nextUrl = startUrl;
for (let page = 0; page < maxPages && nextUrl; page += 1) {
if (page > 0) {
await sleep(1000);
}
const res = await fetchWithRetry(nextUrl, {
headers: {
Accept: 'application/json',
'User-Agent': 'FEDL Importer'
}
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Pointercrate API responded with ${res.status}: ${text.slice(0, 280)}`);
}
const items = await res.json();
if (!Array.isArray(items)) {
throw new Error('Unexpected pointercrate API response');
}
results.push(...items);
const links = parseLinkHeader(getLinkHeader(res));
nextUrl = links.next ? new URL(links.next, 'https://pointercrate.com').toString() : null;
}
return results;
}
async function fetchPointercrateRecords(maxPages = 20, perPage = 100) {
const qs = new URLSearchParams({ limit: String(perPage), status: 'approved' }).toString();
return fetchPointercrateRecordsStartingAt(`https://pointercrate.com/api/v1/records/?${qs}`, maxPages);
}
/** Records API `player=` expects numeric id, not display name. Resolve name → id via /players/. */
async function resolvePointercratePlayerIds(rawQuery) {
const q = String(rawQuery || '').trim();
if (!q) {
return [];
}
if (/^\d+$/.test(q)) {
return [q];
}
const ql = q.toLowerCase();
const headers = { Accept: 'application/json', 'User-Agent': 'FEDL Importer' };
const exactParams = new URLSearchParams({ limit: '100', name: q });
let players = await fetchPointercrateRecordsStartingAt(`https://pointercrate.com/api/v1/players/?${exactParams}`, 20);
let ids = players
.filter(p => p && p.name && String(p.name).trim().toLowerCase() === ql)
.map(p => String(p.id));
if (ids.length) {
return [...new Set(ids)];
}
const containsParams = new URLSearchParams({ limit: '100', name_contains: q });
players = await fetchPointercrateRecordsStartingAt(`https://pointercrate.com/api/v1/players/?${containsParams}`, 30);
ids = players
.filter(p => p && p.name && String(p.name).trim().toLowerCase() === ql)
.map(p => String(p.id));
if (ids.length) {
return [...new Set(ids)];
}
const loose = players.filter(
p => p && p.name && String(p.name).toLowerCase().includes(ql)
);
if (loose.length === 1) {
return [String(loose[0].id)];
}
if (loose.length > 1) {
throw new Error(
`Multiple Pointercrate players match "${q}". Use the exact list name or open the player on pointercrate.com and use their numeric id.`
);
}
throw new Error(
`No Pointercrate player matched "${q}". Check spelling or paste the numeric player id from the list profile.`
);
}
async function fetchPointercrateRecordsForPlayerIds(playerIds) {
const seen = new Set();
const out = [];
for (let i = 0; i < playerIds.length; i += 1) {
const id = playerIds[i];
const qs = new URLSearchParams({ limit: '100', status: 'approved', player: id });
const url = `https://pointercrate.com/api/v1/records/?${qs}`;
const chunk = await fetchPointercrateRecordsStartingAt(url, 30);
for (const r of chunk) {
const rid = r && r.id != null ? r.id : null;
if (rid != null && !seen.has(rid)) {
seen.add(rid);
out.push(r);
}
}
if (i < playerIds.length - 1) {
await sleep(600);
}
}
return out;
}
function filterPointercrateRecordsByQuery(records, filter, rawQuery) {
const trimmed = String(rawQuery || '').trim();
if (!trimmed) {
return [];
}
const ql = trimmed.toLowerCase();
if (filter === 'player') {
if (/^\d+$/.test(trimmed)) {
return records.filter(r => String(r.player && r.player.id != null ? r.player.id : '') === trimmed);
}
return records.filter(r => {
const name = String(r.player && r.player.name ? r.player.name : '')
.trim()
.toLowerCase();
return name === ql;
});
}
if (filter === 'level') {
if (/^\d+$/.test(trimmed)) {
return records.filter(r => String(r.demon && r.demon.id != null ? r.demon.id : '') === trimmed);
}
return records.filter(r => {
const dname = String(r.demon && r.demon.name ? r.demon.name : r.demon && r.demon.title ? r.demon.title : '')
.trim()
.toLowerCase();
return dname === ql;
});
}
return [];
}
/**
* Pointercrate: `demon_contains` / `player_contains` on records do not match display names reliably.
* Player: resolve id via /players/ then records ?player=id. Level: exact demon name or demon_id only.
*/
async function fetchPointercrateFiltered(filter, rawQuery) {
const query = String(rawQuery || '').trim();
if (!query) {
throw new Error('Query is required');
}
if (filter === 'player') {
const ids = await resolvePointercratePlayerIds(query);
const records = await fetchPointercrateRecordsForPlayerIds(ids);
return filterPointercrateRecordsByQuery(records, 'player', query);
}
if (filter === 'level') {
if (/^\d+$/.test(query)) {
const qs = new URLSearchParams({ limit: '100', status: 'approved', demon_id: query });
const url = `https://pointercrate.com/api/v1/records/?${qs}`;
const records = await fetchPointercrateRecordsStartingAt(url, 30);
return filterPointercrateRecordsByQuery(records, 'level', query);
}
const qs = new URLSearchParams({ limit: '100', status: 'approved', demon: query });
const url = `https://pointercrate.com/api/v1/records/?${qs}`;
const records = await fetchPointercrateRecordsStartingAt(url, 30);
const narrowed = filterPointercrateRecordsByQuery(records, 'level', query);
if (narrowed.length || !records.length) {
return narrowed;
}
throw new Error(
`No approved Pointercrate records for demon "${query}". Use the exact demon name as shown on the list, or the numeric demon id.`
);
}
throw new Error('filter must be player or level');
}
function filterAredlRecordsByQuery(records, filter, rawQuery) {
const trimmed = String(rawQuery || '').trim();
if (!trimmed) {
return [];
}
const query = trimmed.toLowerCase();
if (filter === 'player') {
const exact = records.filter(record => {
const a = String(record.submitted_by && record.submitted_by.global_name ? record.submitted_by.global_name : '')
.trim()
.toLowerCase();
const b = String(record.submitted_by && record.submitted_by.username ? record.submitted_by.username : '')
.trim()
.toLowerCase();
return a === query || b === query;
});
if (exact.length) {
return exact;
}
return records.filter(record => {
const a = String(record.submitted_by && record.submitted_by.global_name ? record.submitted_by.global_name : '')
.trim()
.toLowerCase();
const b = String(record.submitted_by && record.submitted_by.username ? record.submitted_by.username : '')
.trim()
.toLowerCase();
return (a && a.includes(query)) || (b && b.includes(query));
});
}
if (filter === 'level') {
if (/^\d+$/.test(trimmed)) {
return records.filter(record => {
const id = String(record.level && record.level.id != null ? record.level.id : '').trim();
return id === trimmed;
});
}
const exact = records.filter(record => {
const level = String(record.level && record.level.name ? record.level.name : '')
.trim()
.toLowerCase();
return level === query;
});
if (exact.length) {
return exact;
}
return records.filter(record => {
const level = String(record.level && record.level.name ? record.level.name : '')
.trim()
.toLowerCase();
return level && level.includes(query);
});
}
return [];
}
async function fetchAredlRecords(maxPages = 20, perPage = 100) {
const authHeaders = getAredlAuthHeaders();
if (!Object.keys(authHeaders).length) {
throw new Error('AREDL authentication is not configured on the server. Set AREDL_API_KEY or AREDL_ACCESS_TOKEN.');
}
const results = [];
let page = 1;
while (page <= maxPages) {
const url = `https://api.aredl.net/v2/api/aredl/records?page=${page}&per_page=${perPage}`;
const res = await fetchWithRetry(url, {
headers: Object.assign({ Accept: 'application/json', 'User-Agent': 'FEDL Importer' }, authHeaders)
});
if (!res.ok) {
const text = await res.text();
throw new Error(`AREDL API responded with ${res.status}: ${text}`);
}
const body = await res.json();
const items = Array.isArray(body.data) ? body.data : [];
results.push(...items);
if (!body.pages || page >= body.pages || items.length < perPage) break;
await sleep(1000);
page += 1;
}
return results;
}
function mapPointercrateRecord(record) {
return {
playerName: String(record.player?.name || record.player?.global_name || '').trim(),
levelTitle: String(record.demon?.name || record.demon?.title || '').trim(),
videoUrl: String(record.video || '').trim(),
percent: String(record.progress != null ? record.progress : 100).trim(),
rawFootageUrl: '',
notes: `Imported from Pointercrate record ${record.id}`,
reviewNotes: `Imported from Pointercrate record ${record.id}`
};
}
function mapAredlRecord(record) {
return {
playerName: String(record.submitted_by?.global_name || record.submitted_by?.username || '').trim(),
levelTitle: String(record.level?.name || '').trim(),
videoUrl: String(record.video_url || '').trim(),
percent: String(record.progress != null ? record.progress : 100).trim(),
rawFootageUrl: '',
notes: `Imported from AREDL record ${record.id}`,
reviewNotes: `Imported from AREDL record ${record.id}`
};
}
function normalizeRun(payload, existingRun) {
return {
id: existingRun && existingRun.id ? existingRun.id : `run_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`,
playerName: String(payload.playerName || existingRun?.playerName || '').trim(),
levelTitle: String(payload.levelTitle || existingRun?.levelTitle || '').trim(),
videoUrl: String(payload.videoUrl || existingRun?.videoUrl || '').trim(),
percent: String(payload.percent || existingRun?.percent || '100').trim(),
rawFootageUrl: String(payload.rawFootageUrl || existingRun?.rawFootageUrl || '').trim(),
notes: String(payload.notes || existingRun?.notes || '').trim(),
status: String(payload.status || existingRun?.status || 'pending').trim().toLowerCase(),
reviewedBy: String(payload.reviewedBy || existingRun?.reviewedBy || '').trim(),
reviewNotes: String(payload.reviewNotes || existingRun?.reviewNotes || '').trim(),
submittedAt: existingRun?.submittedAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
accountUserId: existingRun?.accountUserId || '',
accountUsername: existingRun?.accountUsername || ''
};
}
const MAX_SAVED_RUNS_PER_USER = 48;
function sanitizeSavedRuns(raw) {
if (!Array.isArray(raw)) {
return [];
}
const out = [];
for (let i = 0; i < raw.length && out.length < MAX_SAVED_RUNS_PER_USER; i += 1) {
const item = raw[i];
if (!item || typeof item !== 'object') continue;
const playerName = String(item.playerName || '').trim().slice(0, 120);
const levelTitle = String(item.levelTitle || '').trim().slice(0, 280);
if (!playerName || !levelTitle) continue;
out.push({
id: String(item.id || `sv_${Date.now().toString(36)}_${i}_${Math.random().toString(36).slice(2, 8)}`).slice(0, 96),
playerName,
levelTitle,
videoUrl: String(item.videoUrl || '').trim().slice(0, 2048),
percent: String(item.percent != null ? item.percent : '100').trim().slice(0, 12) || '100',
rawFootageUrl: String(item.rawFootageUrl || '').trim().slice(0, 2048),
notes: String(item.notes || '').trim().slice(0, 8000),
savedAt: String(item.savedAt || new Date().toISOString()).slice(0, 48)
});
}
return out;
}
const ROULETTE_SLOT_KEYS = ['1', '2', '3'];
function emptyRouletteSlots() {
return { '1': null, '2': null, '3': null };
}
function sanitizeRouletteSlots(raw) {
const out = emptyRouletteSlots();
if (!raw || typeof raw !== 'object') {
return out;
}
for (const k of ROULETTE_SLOT_KEYS) {
const v = raw[k];
if (v == null || typeof v !== 'object') {
continue;
}
const title = String(v.title || '').trim().slice(0, 280);
if (!title) {
continue;
}
out[k] = {
title,
position: String(v.position || '').trim().slice(0, 32),
level: String(v.level || '').trim().slice(0, 120),
url: String(v.url || '').trim().slice(0, 2048),
levelId: String(v.levelId || '').trim().slice(0, 64),
noteSource: v.noteSource === 'api' ? 'api' : 'file',
percent: String(v.percent != null ? v.percent : '').trim().slice(0, 12),
savedAt: String(v.savedAt || new Date().toISOString()).slice(0, 48)
};
}
return out;
}
function serveFile(reqPath, res) {
let filePath = path.join(appRoot, reqPath === '/' ? 'index.html' : reqPath.slice(1));
filePath = path.normalize(filePath);
if (!filePath.startsWith(appRoot)) {
setCors(res);
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Forbidden');
return;
}
fs.readFile(filePath, (err, data) => {
if (err) {
setCors(res);
res.writeHead(err.code === 'ENOENT' ? 404 : 500, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(err.code === 'ENOENT' ? 'Not found' : 'Server error');
return;
}
const ext = path.extname(filePath).toLowerCase();
setCors(res);
res.writeHead(200, {
'Content-Type': contentTypes[ext] || 'application/octet-stream',
'Cache-Control': ext === '.html' ? 'no-store' : 'no-cache'
});
res.end(data);
});
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
let pathname = url.pathname;
if (pathname.startsWith(BASE)) {
pathname = pathname.slice(BASE.length) || '/';
}
if (req.method === 'OPTIONS') {
setCors(res);
res.writeHead(204);
res.end();
return;
}
if (req.method === 'POST' && pathname === '/api/auth/signup') {
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 65536) req.destroy();
});
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const username = normalizeUsername(payload.username);
const password = String(payload.password || '');
if (!usernameOk(username)) {
sendJson(res, 400, {
error: 'Username must be 3-24 characters: lowercase letters, numbers, or underscore.'
});
return;
}
if (password.length < 8) {
sendJson(res, 400, { error: 'Password must be at least 8 characters.' });
return;
}
const users = readUsers();
if (users.some(u => u.username === username)) {
sendJson(res, 409, { error: 'That username is already taken.' });
return;
}
const { salt, hash } = hashPassword(password);
const id = `usr_${Date.now().toString(36)}${crypto.randomBytes(4).toString('hex')}`;
users.push({
id,
username,
passwordHash: hash,
salt,
createdAt: new Date().toISOString()
});
writeUsers(users);
const token = createSession(id, username);
sendJson(res, 201, { ok: true, token, userId: id, username });
} catch (error) {
sendJson(res, 400, { error: 'Invalid signup request' });
}
});
return;
}
if (req.method === 'POST' && pathname === '/api/auth/login') {
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 65536) req.destroy();
});
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const username = normalizeUsername(payload.username);
const password = String(payload.password || '');
const users = readUsers();
const user = users.find(u => u.username === username);
if (!user || !verifyPassword(password, user.salt, user.passwordHash)) {
sendJson(res, 401, { error: 'Invalid username or password.' });
return;
}
const token = createSession(user.id, user.username);
sendJson(res, 200, { ok: true, token, userId: user.id, username: user.username });
} catch (error) {
sendJson(res, 400, { error: 'Invalid login request' });
}
});
return;
}
if (req.method === 'POST' && pathname === '/api/auth/logout') {
const token = getBearerToken(req);
if (token) {
deleteSession(token);
}
sendJson(res, 200, { ok: true });
return;
}
if (req.method === 'GET' && pathname === '/api/auth/me') {
const sess = getSessionFromRequest(req);
if (!sess) {
sendJson(res, 401, { error: 'Not signed in' });
return;
}
const users = readUsers();
const user = users.find(u => u.id === sess.userId);
sendJson(res, 200, {
userId: sess.userId,
username: sess.username
});
return;
}
if (req.method === 'POST' && pathname === '/api/auth/request-password-reset') {
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 65536) req.destroy();
});
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const identifier = String(payload.identifier || '').trim().toLowerCase();
if (!identifier) {
sendJson(res, 400, { error: 'Enter your username.' });
return;
}
const users = readUsers();
const user = users.find(u => u.username === identifier);
if (user) {
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = hashToken(token);
const resetTokens = readResetTokensRaw();
const expiresAt = new Date(Date.now() + PASSWORD_RESET_TTL_MS).toISOString();
resetTokens[tokenHash] = {
userId: user.id,
expiresAt,
createdAt: new Date().toISOString()
};
writeResetTokens(cleanResetTokens(resetTokens));
const messages = readMessages();
const newMessage = {
id: `msg_${Date.now().toString(36)}${crypto.randomBytes(4).toString('hex')}`,
fromUserId: 'system',
fromUsername: 'FEDL System',
toUserId: user.id,
toUsername: user.username,
content: `Your password reset code is: ${token}\n\nThis code expires in 1 hour. If you did not request a reset, you can ignore this message.`,
timestamp: new Date().toISOString(),
read: false,
type: 'password_reset'
};
messages.unshift(newMessage);
writeMessages(messages);
sendEvent('messages-update', { userId: user.id });
sendJson(res, 200, {
ok: true,
message: 'Check your messages for the reset code.'
});
return;
}
sendJson(res, 200, {
ok: true,
message: 'If that account exists, a reset code has been sent to their messages.'
});
} catch (error) {
console.error(`[FEDL] Password reset request failed: ${error.message}`);
sendJson(res, 400, { error: 'Invalid password reset request' });
}
});
return;
}
if (req.method === 'POST' && pathname === '/api/auth/reset-password') {
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 65536) req.destroy();
});
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const token = String(payload.token || '').trim();
const newPassword = String(payload.newPassword || '');
if (!token) {
sendJson(res, 400, { error: 'Reset code is required.' });
return;
}
if (newPassword.length < 8) {
sendJson(res, 400, { error: 'Password must be at least 8 characters.' });
return;
}
const tokens = readResetTokens();
const tokenHash = hashToken(token);
const row = tokens[tokenHash];
if (!row || !row.userId) {
sendJson(res, 400, { error: 'That reset code is invalid or has expired.' });
return;
}
const users = readUsers();
const user = users.find(u => u.id === row.userId);
if (!user) {
delete tokens[tokenHash];
writeResetTokens(cleanResetTokens(tokens));
sendJson(res, 400, { error: 'That reset code is invalid or has expired.' });
return;
}
const { salt, hash } = hashPassword(newPassword);
user.salt = salt;
user.passwordHash = hash;
user.updatedAt = new Date().toISOString();
writeUsers(users);
delete tokens[tokenHash];
writeResetTokens(cleanResetTokens(tokens));
revokeUserSessions(user.id);
removeResetTokensForUser(user.id);
sendJson(res, 200, { ok: true });
} catch (error) {
sendJson(res, 400, { error: 'Invalid password reset request' });
}
});
return;
}
if (req.method === 'GET' && pathname === '/api/account') {
const sess = getSessionFromRequest(req);
if (!sess) {
sendJson(res, 401, { error: 'Not signed in' });
return;
}
const users = readUsers();
const user = users.find(u => u.id === sess.userId);
if (!user) {
sendJson(res, 404, { error: 'Account not found' });
return;
}
sendJson(res, 200, {
userId: user.id,
username: user.username,
createdAt: user.createdAt || ''
});
return;
}
if (req.method === 'PUT' && pathname === '/api/account/password') {
const sess = getSessionFromRequest(req);
if (!sess) {
sendJson(res, 401, { error: 'Not signed in' });
return;
}
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 65536) req.destroy();
});
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const currentPassword = String(payload.currentPassword || '');
const newPassword = String(payload.newPassword || '');
if (newPassword.length < 8) {
sendJson(res, 400, { error: 'Password must be at least 8 characters.' });
return;
}
const users = readUsers();
const user = users.find(u => u.id === sess.userId);
if (!user) {
sendJson(res, 404, { error: 'Account not found' });
return;
}
if (!verifyPassword(currentPassword, user.salt, user.passwordHash)) {
sendJson(res, 401, { error: 'Your current password is incorrect.' });
return;
}
const { salt, hash } = hashPassword(newPassword);
user.salt = salt;
user.passwordHash = hash;
user.updatedAt = new Date().toISOString();
writeUsers(users);
removeResetTokensForUser(user.id);
revokeUserSessions(user.id);
const token = createSession(user.id, user.username);
sendJson(res, 200, { ok: true, token });
} catch (error) {
sendJson(res, 400, { error: 'Invalid password update request' });
}
});
return;
}
if (req.method === 'POST' && pathname === '/api/account/password-reset-email') {
const sess = getSessionFromRequest(req);
if (!sess) {
sendJson(res, 401, { error: 'Not signed in' });
return;
}
const users = readUsers();
const user = users.find(u => u.id === sess.userId);
if (!user) {
sendJson(res, 404, { error: 'Account not found' });
return;
}
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = hashToken(token);
const resetTokens = readResetTokensRaw();
const expiresAt = new Date(Date.now() + PASSWORD_RESET_TTL_MS).toISOString();
resetTokens[tokenHash] = {
userId: user.id,
expiresAt,
createdAt: new Date().toISOString()
};
writeResetTokens(cleanResetTokens(resetTokens));
const messages = readMessages();
const newMessage = {
id: `msg_${Date.now().toString(36)}${crypto.randomBytes(4).toString('hex')}`,
fromUserId: 'system',
fromUsername: 'FEDL System',
toUserId: user.id,
toUsername: user.username,
content: `Your password reset code is: ${token}\n\nThis code expires in 1 hour. If you did not request a reset, you can ignore this message.`,
timestamp: new Date().toISOString(),
read: false,
type: 'password_reset'
};
messages.unshift(newMessage);
writeMessages(messages);
sendEvent('messages-update', { userId: user.id });
sendJson(res, 200, { ok: true, expiresAt });
return;
}
if (req.method === 'GET' && pathname === '/api/user/state') {
const token = getBearerToken(req);
const sess = findSession(token);
if (!sess) {
sendJson(res, 401, { error: 'Not signed in' });
return;
}
const map = readUserDataMap();
const row = map[sess.userId] || {};
const data = {
roulettePick: row.roulettePick != null ? row.roulettePick : null,
levelPercents: row.levelPercents && typeof row.levelPercents === 'object' ? row.levelPercents : {},
savedRuns: Array.isArray(row.savedRuns) ? row.savedRuns : [],
rouletteSlots: sanitizeRouletteSlots(row.rouletteSlots)
};
sendJson(res, 200, { data });
return;
}
if (req.method === 'PUT' && pathname === '/api/user/state') {
const token = getBearerToken(req);
const sess = findSession(token);
if (!sess) {
sendJson(res, 401, { error: 'Not signed in' });
return;
}
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 2 * 1024 * 1024) req.destroy();
});
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const incoming = payload.data;
if (!incoming || typeof incoming !== 'object') {
sendJson(res, 400, { error: 'A "data" object is required' });
return;
}
const map = readUserDataMap();
map[sess.userId] = {
roulettePick: incoming.roulettePick != null ? incoming.roulettePick : null,
levelPercents:
incoming.levelPercents && typeof incoming.levelPercents === 'object' ? incoming.levelPercents : {},
savedRuns: sanitizeSavedRuns(incoming.savedRuns),
rouletteSlots: sanitizeRouletteSlots(incoming.rouletteSlots)
};
writeUserDataMap(map);
sendJson(res, 200, { ok: true });
} catch (error) {
sendJson(res, 400, { error: 'Invalid payload' });
}
});
return;
}
if (req.method === 'GET' && pathname === '/api/posts') {
try {
const posts = readPosts();
sendJson(res, 200, { items: posts });
} catch (error) {
sendJson(res, 500, { error: 'Could not read posts' });
}
return;
}
if (req.method === 'POST' && pathname === '/api/posts') {
const sess = findSession(getBearerToken(req));
if (!sess) {
sendJson(res, 401, { error: 'Sign in to post' });
return;
}
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 65536) req.destroy();
});
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const level = String(payload.level || '').trim().slice(0, 200);
const content = String(payload.content || '').trim().slice(0, 4000);
if (!level || !content) {
sendJson(res, 400, { error: 'Level and content are required' });
return;
}
const posts = readPosts();
const newPost = {
id: `post_${Date.now().toString(36)}${crypto.randomBytes(4).toString('hex')}`,
authorId: sess.userId,
authorName: sess.username,
level,
content,
timestamp: new Date().toISOString(),
likes: [],
comments: []
};
posts.unshift(newPost);
writePosts(posts.slice(0, 100));
sendEvent('posts-update', { updatedAt: newPost.timestamp });
sendJson(res, 201, { ok: true, item: newPost });
} catch (error) {
sendJson(res, 400, { error: 'Invalid post payload' });
}
});
return;
}
if (req.method === 'DELETE' && pathname.startsWith('/api/posts/')) {
const sess = findSession(getBearerToken(req));
if (!sess) {
sendJson(res, 401, { error: 'Sign in required' });
return;
}
const postId = pathname.slice('/api/posts/'.length);
if (!postId) {
sendJson(res, 400, { error: 'Post id required' });
return;
}
const posts = readPosts();
const index = posts.findIndex(p => p.id === postId);
if (index === -1) {
sendJson(res, 404, { error: 'Post not found' });
return;
}
if (posts[index].authorId !== sess.userId) {
sendJson(res, 403, { error: 'Not your post' });
return;
}
posts.splice(index, 1);
writePosts(posts);
sendJson(res, 200, { ok: true });
return;
}
if (req.method === 'POST' && pathname.startsWith('/api/posts/') && pathname.endsWith('/like')) {
const sess = findSession(getBearerToken(req));
if (!sess) {
sendJson(res, 401, { error: 'Sign in to like' });
return;
}
const postId = pathname.slice('/api/posts/'.length, -'/like'.length);
const allPosts = readPosts();
const post = allPosts.find(p => p.id === postId);
if (!post) {
sendJson(res, 404, { error: 'Post not found' });
return;
}
if (!post.likes) post.likes = [];
const idx = post.likes.indexOf(sess.userId);
if (idx === -1) {
post.likes.push(sess.userId);
} else {
post.likes.splice(idx, 1);
}
writePosts(allPosts);
sendJson(res, 200, { ok: true, liked: idx === -1, count: post.likes.length });
return;
}
if (req.method === 'POST' && pathname.startsWith('/api/posts/') && pathname.endsWith('/comment')) {
const sess = findSession(getBearerToken(req));
if (!sess) {
sendJson(res, 401, { error: 'Sign in to comment' });
return;
}
const postId = pathname.slice('/api/posts/'.length, -'/comment'.length);
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 4096) req.destroy();
});
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const text = String(payload.text || '').trim().slice(0, 500);
if (!text) {
sendJson(res, 400, { error: 'Comment text required' });
return;
}
const allPosts = readPosts();
const post = allPosts.find(p => p.id === postId);
if (!post) {
sendJson(res, 404, { error: 'Post not found' });
return;
}
if (!post.comments) post.comments = [];
post.comments.push({
id: `cmt_${Date.now().toString(36)}${crypto.randomBytes(3).toString('hex')}`,
authorId: sess.userId,
authorName: sess.username,
text,
timestamp: new Date().toISOString()
});
writePosts(allPosts);
sendEvent('posts-update', { updatedAt: new Date().toISOString() });
sendJson(res, 201, { ok: true, comment: post.comments[post.comments.length - 1] });
} catch (error) {
sendJson(res, 400, { error: 'Invalid comment payload' });
}
});
return;
}
if (req.method === 'DELETE' && pathname.startsWith('/api/posts/') && pathname.includes('/comment/')) {
const sess = findSession(getBearerToken(req));
if (!sess) {
sendJson(res, 401, { error: 'Sign in required' });
return;
}
const match = pathname.match(/^\/api\/posts\/([^/]+)\/comment\/(.+)$/);
if (!match) {
sendJson(res, 400, { error: 'Invalid path' });
return;
}
const postId = match[1];
const commentId = match[2];
const allPosts = readPosts();
const post = allPosts.find(p => p.id === postId);
if (!post) {
sendJson(res, 404, { error: 'Post not found' });
return;
}
const cidx = post.comments ? post.comments.findIndex(c => c.id === commentId) : -1;
if (cidx === -1) {
sendJson(res, 404, { error: 'Comment not found' });
return;
}
if (post.comments[cidx].authorId !== sess.userId) {
sendJson(res, 403, { error: 'Not your comment' });
return;
}
post.comments.splice(cidx, 1);
writePosts(allPosts);
sendJson(res, 200, { ok: true });
return;
}
if (req.method === 'GET' && pathname === '/api/bugreports') {
if (!requireAdmin(req, res)) return;
try {
sendJson(res, 200, { items: readBugReports() });
} catch (error) {
sendJson(res, 500, { error: 'Could not read bug reports' });
}
return;
}
if (req.method === 'POST' && pathname === '/api/bugreports') {
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 65536) req.destroy();
});
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const category = String(payload.category || 'other').trim().toLowerCase();
const subject = String(payload.subject || '').trim().slice(0, 200);
const description = String(payload.description || '').trim().slice(0, 4000);
const email = String(payload.email || '').trim().slice(0, 200);
if (!subject || !description) {
sendJson(res, 400, { error: 'Subject and description are required' });
return;
}
const sess = findSession(getBearerToken(req));
const newReport = {
id: `bug_${Date.now().toString(36)}${crypto.randomBytes(4).toString('hex')}`,
category,
subject,
description,
email,
submittedAt: new Date().toISOString(),
status: 'open',
accountUserId: sess ? sess.userId : '',
accountUsername: sess ? sess.username : ''
};
const reports = readBugReports();
reports.unshift(newReport);
writeBugReports(reports);
sendEvent('bugreports-update', { updatedAt: newReport.submittedAt });
sendJson(res, 201, { ok: true, item: newReport });
} catch (error) {
sendJson(res, 400, { error: 'Invalid bug report payload' });
}
});
return;
}
if ((req.method === 'PUT' || req.method === 'DELETE') && pathname.startsWith('/api/bugreports/')) {
if (!requireAdmin(req, res)) return;
const reportId = pathname.slice('/api/bugreports/'.length);
if (!reportId) {
sendJson(res, 400, { error: 'Missing report ID' });
return;
}
const reports = readBugReports();
const index = reports.findIndex(r => r.id === reportId);
if (index === -1) {
sendJson(res, 404, { error: 'Report not found' });
return;
}
if (req.method === 'DELETE') {
reports.splice(index, 1);
writeBugReports(reports);
sendEvent('bugreports-update', { updatedAt: new Date().toISOString() });
sendJson(res, 200, { ok: true });
return;
}
let body = '';
req.on('data', chunk => { body += chunk; if (body.length > 65536) req.destroy(); });
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
reports[index] = {
...reports[index],
category: String(payload.category || reports[index].category || 'other').trim().toLowerCase(),
subject: String(payload.subject || reports[index].subject || '').trim().slice(0, 200),
description: String(payload.description || reports[index].description || '').trim().slice(0, 4000),
email: String(payload.email || reports[index].email || '').trim().slice(0, 200),
status: String(payload.status || reports[index].status || 'open').trim().toLowerCase(),
updatedAt: new Date().toISOString()
};
writeBugReports(reports);
sendEvent('bugreports-update', { updatedAt: reports[index].updatedAt });
sendJson(res, 200, { ok: true, item: reports[index] });
} catch (error) {
sendJson(res, 400, { error: 'Invalid bug report update payload' });
}
});
return;
}
if (req.method === 'GET' && pathname === '/api/list') {
try {
const text = readDataText();
sendJson(res, 200, { items: parseData(text), text });
} catch (error) {
sendJson(res, 500, { error: 'Could not read server/data.txt' });
}
return;
}
if (req.method === 'PUT' && pathname === '/api/list') {
if (!requireAdmin(req, res)) return;
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 5 * 1024 * 1024) {
req.destroy();
}
});
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const text = String(payload.text || '').trim();
writeDataText(text);
sendEvent('list-update', { updatedAt: new Date().toISOString() });
sendJson(res, 200, { ok: true });
} catch (error) {
sendJson(res, 400, { error: 'Invalid list payload' });
}
});
return;
}
if (req.method === 'GET' && pathname === '/events') {
setCors(res);
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive'
});
res.write('retry: 3000\n\n');
clients.add(res);
req.on('close', () => clients.delete(res));
return;
}
if (req.method === 'GET' && pathname === '/api/runs') {
try {
sendJson(res, 200, { items: readRuns() });
} catch (error) {
sendJson(res, 500, { error: 'Could not read server/runs.json' });
}
return;
}
if (req.method === 'POST' && pathname === '/api/runs') {
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 2 * 1024 * 1024) req.destroy();
});
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const nextRun = normalizeRun(payload);
const sess = findSession(getBearerToken(req));
if (sess) {
nextRun.accountUserId = sess.userId;
nextRun.accountUsername = sess.username;
}
if (!nextRun.playerName || !nextRun.levelTitle || !nextRun.videoUrl || !nextRun.percent) {
sendJson(res, 400, { error: 'playerName, levelTitle, videoUrl, and percent are required' });
return;
}
const runs = readRuns();
runs.unshift(nextRun);
writeRuns(runs);
sendEvent('runs-update', { updatedAt: nextRun.updatedAt });
sendJson(res, 201, { ok: true, item: nextRun });
} catch (error) {
sendJson(res, 400, { error: 'Invalid run payload' });
}
});
return;
}
if ((req.method === 'PUT' || req.method === 'DELETE') && pathname.startsWith('/api/runs/')) {
if (!requireAdmin(req, res)) return;
const runId = pathname.slice('/api/runs/'.length);
if (!runId) {
sendJson(res, 400, { error: 'Run id is required' });
return;
}
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 2 * 1024 * 1024) req.destroy();
});
req.on('end', () => {
try {
const runs = readRuns();
const index = runs.findIndex(run => run.id === runId);
if (index === -1) {
sendJson(res, 404, { error: 'Run not found' });
return;
}
if (req.method === 'DELETE') {
runs.splice(index, 1);
writeRuns(runs);
sendEvent('runs-update', { updatedAt: new Date().toISOString() });
sendJson(res, 200, { ok: true });
return;
}
const payload = JSON.parse(body || '{}');
runs[index] = normalizeRun(payload, runs[index]);
writeRuns(runs);
sendEvent('runs-update', { updatedAt: runs[index].updatedAt });
sendJson(res, 200, { ok: true, item: runs[index] });
} catch (error) {
sendJson(res, 400, { error: 'Invalid run update payload' });
}
});
return;
}
if (req.method === 'POST' && pathname === '/api/runs/bulk-approve') {
if (!requireAdmin(req, res)) return;
let body = '';
req.on('data', chunk => { body += chunk; if (body.length > 65536) req.destroy(); });
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const playerQuery = String(payload.playerName || '').trim();
if (!playerQuery) {
sendJson(res, 400, { error: 'playerName is required' });
return;
}
const normalizedQuery = playerQuery.toLowerCase();
const reviewNotes = String(payload.reviewNotes || 'Bulk approved').trim();
const runs = readRuns();
let approved = 0;
let updatedAt = new Date().toISOString();
runs.forEach((run, index) => {
const name = String(run.playerName || '').trim().toLowerCase();
const status = String(run.status || 'pending').toLowerCase();
if (name === normalizedQuery && status === 'pending') {
runs[index] = normalizeRun({
...run,
status: 'approved',
reviewNotes,
reviewedBy: 'FEDL Admin'
}, run);
updatedAt = runs[index].updatedAt;
approved += 1;
}
});
if (approved) {
writeRuns(runs);
sendEvent('runs-update', { updatedAt });
}
sendJson(res, 200, { ok: true, approved, playerName: playerQuery });
} catch (error) {
sendJson(res, 400, { error: 'Invalid bulk-approve payload' });
}
});
return;
}
if (req.method === 'POST' && pathname === '/api/import/targeted') {
if (!requireAdmin(req, res)) return;
let body = '';
req.on('data', chunk => { body += chunk; if (body.length > 65536) req.destroy(); });
req.on('end', () => {
(async () => {
try {
const payload = JSON.parse(body || '{}');
const source = String(payload.source || '').toLowerCase();
const filter = String(payload.filter || '').toLowerCase();
const query = String(payload.query || '').trim();
if (!['pointercrate', 'aredl'].includes(source)) {
sendJson(res, 400, { error: 'source must be pointercrate or aredl' });
return;
}
if (!['player', 'level'].includes(filter)) {
sendJson(res, 400, { error: 'filter must be player or level' });
return;
}
if (!query) {
sendJson(res, 400, { error: 'query is required' });
return;
}
const validRunNote = 'Valid run';
let records = [];
if (source === 'pointercrate') {
records = await fetchPointercrateFiltered(filter, query);
} else {
const pool = await fetchAredlRecords(40, 100);
records = filterAredlRecordsByQuery(pool, filter, query);
}
const label = source === 'pointercrate' ? 'Pointercrate' : 'AREDL';
const mapped = records.map(r => (source === 'pointercrate' ? mapPointercrateRecord(r) : mapAredlRecord(r)));
const summary = appendImportedRuns(mapped, label, { validRunNote: validRunNote });
sendJson(res, 200, Object.assign({ ok: true, source, filter, query, matched: records.length, note: validRunNote }, summary));
} catch (error) {
sendJson(res, 500, { error: String(error.message || error) });
}
})();
});
return;
}
if (req.method === 'POST' && pathname === '/api/import/pointercrate') {
if (!requireAdmin(req, res)) return;
fetchPointercrateRecords()
.then(records => appendImportedRuns(records.map(mapPointercrateRecord), 'Pointercrate'))
.then(summary => sendJson(res, 200, Object.assign({ ok: true, source: 'pointercrate' }, summary)))
.catch(error => sendJson(res, 500, { error: String(error.message || error) }));
return;
}
if (req.method === 'POST' && pathname === '/api/import/aredl') {
if (!requireAdmin(req, res)) return;
fetchAredlRecords()
.then(records => appendImportedRuns(records.map(mapAredlRecord), 'AREDL'))
.then(summary => sendJson(res, 200, Object.assign({ ok: true, source: 'aredl' }, summary)))
.catch(error => sendJson(res, 500, { error: String(error.message || error) }));
return;
}
const sess = findSession(getBearerToken(req));
if (req.method === 'POST' && pathname === '/api/messages') {
if (!sess) { sendJson(res, 401, { error: 'Login required' }); return; }
let body = '';
req.on('data', chunk => { body += chunk; if (body.length > 65536) req.destroy(); });
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const toUserId = String(payload.toUserId || '').trim();
const content = String(payload.content || '').trim().slice(0, 2000);
if (!toUserId || !content) {
sendJson(res, 400, { error: 'toUserId and content are required' });
return;
}
const users = readUsers();
const toUser = users.find(u => u.id === toUserId);
if (!toUser) {
sendJson(res, 404, { error: 'Recipient user not found' });
return;
}
if (toUserId === sess.userId) {
sendJson(res, 400, { error: 'Cannot send message to yourself' });
return;
}
const messages = readMessages();
const newMessage = {
id: `msg_${Date.now().toString(36)}${crypto.randomBytes(4).toString('hex')}`,
fromUserId: sess.userId,
fromUsername: sess.username,
toUserId,
toUsername: toUser.username,
content,
timestamp: new Date().toISOString(),
read: false
};
messages.unshift(newMessage);
writeMessages(messages);
sendEvent('messages-update', { userId: sess.userId });
sendEvent('messages-update', { userId: toUserId });
sendJson(res, 201, { ok: true, message: newMessage });
} catch (error) {
sendJson(res, 400, { error: 'Invalid message payload' });
}
});
return;
}
if (req.method === 'GET' && pathname === '/api/messages') {
if (!sess) { sendJson(res, 401, { error: 'Login required' }); return; }
const messages = readMessages();
const userMessages = messages.filter(m => m.toUserId === sess.userId || m.fromUserId === sess.userId);
const conversationsMap = new Map();
userMessages.forEach(m => {
const otherId = m.fromUserId === sess.userId ? m.toUserId : m.fromUserId;
const otherName = m.fromUserId === sess.userId ? m.toUsername : m.fromUsername;
if (!conversationsMap.has(otherId)) {
conversationsMap.set(otherId, {
userId: otherId,
username: otherName,
lastMessage: m,
unreadCount: 0
});
}
if (m.toUserId === sess.userId && !m.read) {
conversationsMap.get(otherId).unreadCount++;
}
});
let conversations = Array.from(conversationsMap.values()).sort((a, b) =>
new Date(b.lastMessage.timestamp) - new Date(a.lastMessage.timestamp)
);
const systemMessages = messages.filter(m => m.toUserId === sess.userId && m.fromUserId === 'system');
if (systemMessages.length > 0) {
const lastSystem = systemMessages.reduce((latest, m) =>
new Date(m.timestamp) > new Date(latest.timestamp) ? m : latest
, systemMessages[0]);
const unreadSystem = systemMessages.filter(m => !m.read).length;
conversations.unshift({
userId: 'system',
username: 'System',
lastMessage: lastSystem,
unreadCount: unreadSystem
});
}
sendJson(res, 200, { conversations });
return;
}
if (req.method === 'GET' && pathname === '/api/messages/system') {
if (!sess) { sendJson(res, 401, { error: 'Login required' }); return; }
const messages = readMessages();
const systemMessages = messages.filter(m => m.toUserId === sess.userId && m.fromUserId === 'system')
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
const unreadCount = systemMessages.filter(m => !m.read).length;
sendJson(res, 200, { messages: systemMessages, unreadCount });
return;
}
if (req.method === 'GET' && pathname.startsWith('/api/messages/')) {
if (!sess) { sendJson(res, 401, { error: 'Login required' }); return; }
const otherUserId = pathname.slice('/api/messages/'.length);
if (!otherUserId) {
sendJson(res, 400, { error: 'Missing user ID' });
return;
}
const messages = readMessages();
const userMessages = messages.filter(m =>
(m.fromUserId === sess.userId && m.toUserId === otherUserId) ||
(m.fromUserId === otherUserId && m.toUserId === sess.userId)
).sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
messages.forEach(m => {
if (m.fromUserId === otherUserId && m.toUserId === sess.userId && !m.read) {
m.read = true;
}
});
writeMessages(messages);
sendJson(res, 200, { messages: userMessages });
return;
}
if (req.method === 'POST' && pathname === '/api/users/search') {
if (!sess) { sendJson(res, 401, { error: 'Login required' }); return; }
let body = '';
req.on('data', chunk => { body += chunk; if (body.length > 65536) req.destroy(); });
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const query = String(payload.query || '').trim().toLowerCase();
if (!query || query.length < 2) {
sendJson(res, 400, { error: 'Query must be at least 2 characters' });
return;
}
const users = readUsers();
const results = users
.filter(u => u.username.toLowerCase().includes(query) && u.id !== sess.userId)
.map(u => ({ userId: u.id, username: u.username }))
.slice(0, 10);
sendJson(res, 200, { results });
} catch (error) {
sendJson(res, 400, { error: 'Invalid search payload' });
}
});
return;
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
setCors(res);
res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Method not allowed');
return;
}
serveFile(pathname, res);
});
ensureRunsFile();
safeWatch(dataPath, 'list-update');
safeWatch(legacyDataPath, 'list-update');
safeWatch(runsPath, 'runs-update');
server.on('error', error => {
console.error(`[FEDL] Server failed to start: ${error.message}`);
if (error && error.code) {
console.error(`[FEDL] Error code: ${error.code}`);
}
process.exit(1);
});
server.listen(port, host, () => {
console.log(`FEDL server running at http://${host}:${port}`);
console.log(`Base path: ${BASE}`);
console.log(`Using live list file: ${dataPath}`);
console.log(`Legacy list fallback: ${legacyDataPath}`);
console.log(`Using runs file: ${runsPath}`);
console.log(`Admin password protection: ${adminPassword ? 'enabled' : 'disabled'}`);
});