2054 lines
67 KiB
JavaScript
2054 lines
67 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 bugReportsPath = path.join(__dirname, 'bugreports.json');
|
|
const messagesPath = path.join(__dirname, 'messages.json');
|
|
const configPath = path.join(__dirname, 'config.json');
|
|
|
|
const serverConfig = safeReadJsonFile(configPath, {}, 'config.json');
|
|
const discordWebhookUrl = serverConfig.discordWebhookUrl || '';
|
|
|
|
async function sendDiscordNotification(message) {
|
|
if (!discordWebhookUrl || !discordWebhookUrl.startsWith('http')) {
|
|
return;
|
|
}
|
|
try {
|
|
const https = require('https');
|
|
const payload = JSON.stringify({ content: message });
|
|
const url = new URL(discordWebhookUrl);
|
|
const options = {
|
|
hostname: url.hostname,
|
|
port: 443,
|
|
path: url.pathname + url.search,
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(payload)
|
|
}
|
|
};
|
|
const req = https.request(options, (res) => {
|
|
if (res.statusCode >= 400) {
|
|
console.error(`[FEDL] Discord webhook error: ${res.statusCode}`);
|
|
}
|
|
});
|
|
req.on('error', (e) => console.error(`[FEDL] Discord webhook error: ${e.message}`));
|
|
req.write(payload);
|
|
req.end();
|
|
} catch (e) {
|
|
console.error(`[FEDL] Discord notification failed: ${e.message}`);
|
|
}
|
|
}
|
|
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 safeReadBugReports() {
|
|
try {
|
|
const raw = fs.readFileSync(bugReportsPath, 'utf8');
|
|
if (!String(raw || '').trim()) return [];
|
|
return JSON.parse(raw);
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
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 (pathname.startsWith('/api/posts')) {
|
|
sendJson(res, 410, { error: 'Posts feature removed' });
|
|
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();
|
|
const oldItems = parseData(readDataText());
|
|
const newItems = parseData(text);
|
|
const oldLevels = new Map(oldItems.map(i => [i.title, i.position]));
|
|
const newLevels = new Map(newItems.map(i => [i.title, i.position]));
|
|
const addedLevels = [];
|
|
const movedLevels = [];
|
|
for (const [title, newPos] of newLevels) {
|
|
if (!oldLevels.has(title)) {
|
|
addedLevels.push(title);
|
|
} else if (oldLevels.get(title) !== newPos) {
|
|
movedLevels.push({ title, from: oldLevels.get(title), to: newPos });
|
|
}
|
|
}
|
|
writeDataText(text);
|
|
sendEvent('list-update', { updatedAt: new Date().toISOString() });
|
|
if (addedLevels.length > 0) {
|
|
sendDiscordNotification(`🆕 **New levels added:** ${addedLevels.join(', ')}`);
|
|
}
|
|
if (movedLevels.length > 0) {
|
|
const moves = movedLevels.map(m => `${m.title} (#${m.from} → #${m.to})`).join(', ');
|
|
sendDiscordNotification(`📝 **Level positions changed:** ${moves}`);
|
|
}
|
|
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'}`);
|
|
});
|