const express = require('express'); const fs = require('fs'); const path = require('path'); const { randomUUID } = require('crypto'); const app = express(); const PORT = process.env.PORT || 3000; // On HF Spaces the persistent bucket is mounted at /data; locally falls back to ./data/ const DATA_FILE = process.env.DATA_PATH || path.join(__dirname, 'data', 'results.json'); const ADMIN_TOKEN = process.env.ADMIN_TOKEN || ''; function adminAuth(req, res, next) { if (!ADMIN_TOKEN) { return res.status(503).json({ error: 'Admin access disabled: ADMIN_TOKEN not set' }); } // Accept token via Authorization Bearer OR X-Admin-Token header. // The X-Admin-Token header is needed when the HF Space is private: // HF's proxy requires a valid HF token as the Bearer header, so the // admin token must travel in a separate header. const bearer = (req.headers.authorization || '').replace(/^Bearer\s+/i, ''); const custom = req.headers['x-admin-token'] || ''; if (bearer !== ADMIN_TOKEN && custom !== ADMIN_TOKEN) { return res.status(401).json({ error: 'Unauthorized' }); } next(); } const IMAGES_DIR = path.join(__dirname, 'images'); app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); app.use('/images', express.static(IMAGES_DIR)); // Ensure local image dirs exist (no-op when /data is an external mount) ['images/real', 'images/fake'].forEach(dir => { const p = path.join(__dirname, dir); if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }); // Ensure the data file's parent directory exists, then seed an empty store const dataDir = path.dirname(DATA_FILE); if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); if (!fs.existsSync(DATA_FILE)) { fs.writeFileSync(DATA_FILE, JSON.stringify({ sessions: [], trials: [] }, null, 2)); } const IMAGE_EXT = /\.(jpe?g|png|tiff?|webp|bmp|gif)$/i; function readData() { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); } function writeData(data) { fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2)); } // GET /api/images — list real and fake images app.get('/api/images', (req, res) => { try { const list = (subdir) => fs.readdirSync(path.join(IMAGES_DIR, subdir)) .filter(f => IMAGE_EXT.test(f)) .map(f => ({ id: f, src: `/images/${subdir}/${f}`, type: subdir })); res.json({ real: list('real'), fake: list('fake') }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/trial — save one trial immediately; called on every click app.post('/api/trial', (req, res) => { const { sessionId, trial } = req.body; if (!sessionId || !trial) { return res.status(400).json({ error: 'Invalid payload' }); } const data = readData(); data.trials.push({ session_id: sessionId, player_name: null, timestamp: new Date().toISOString(), ...trial }); writeData(data); res.json({ ok: true }); }); // POST /api/submit — attach a player name to an already-saved session app.post('/api/submit', (req, res) => { const { sessionId, playerName } = req.body; if (!sessionId || !playerName) { return res.status(400).json({ error: 'Invalid payload' }); } const data = readData(); const sessionTrials = data.trials.filter(t => t.session_id === sessionId); if (sessionTrials.length === 0) { return res.status(404).json({ error: 'Session not found' }); } // Attach player name to every trial of this session data.trials = data.trials.map(t => t.session_id === sessionId ? { ...t, player_name: playerName } : t ); const score = sessionTrials.filter(t => t.correct).length; const total = sessionTrials.length; data.sessions.push({ session_id: sessionId, player_name: playerName, timestamp: sessionTrials[0].timestamp, score, total, percentage: Math.round((score / total) * 100) }); writeData(data); res.json({ sessionId, score, total }); }); // GET /api/leaderboard — top 50 sessions by accuracy, then recency app.get('/api/leaderboard', (req, res) => { const { sessions } = readData(); const sorted = [...sessions] .sort((a, b) => b.percentage - a.percentage || new Date(b.timestamp) - new Date(a.timestamp)) .slice(0, 50); res.json(sorted); }); // GET /api/admin/sessions — full session list for the admin dashboard app.get('/api/admin/sessions', (req, res) => { const { sessions } = readData(); res.json([...sessions].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))); }); // GET /api/export — download full raw data (admin only) app.get('/api/export', adminAuth, (req, res) => { res.setHeader('Content-Disposition', 'attachment; filename="2afc_data.json"'); res.sendFile(DATA_FILE); }); // POST /api/admin/clear — reset the entire data store (admin only) app.post('/api/admin/clear', adminAuth, (req, res) => { writeData({ sessions: [], trials: [] }); res.json({ ok: true, message: 'Results cleared' }); }); // POST /api/admin/clear-user — remove all sessions and trials for one player (admin only) app.post('/api/admin/clear-user', adminAuth, (req, res) => { const { playerName } = req.body; if (!playerName) return res.status(400).json({ error: 'playerName required' }); const data = readData(); const sessionsBefore = data.sessions.length; const trialsBefore = data.trials.length; data.sessions = data.sessions.filter(s => s.player_name !== playerName); data.trials = data.trials.filter(t => t.player_name !== playerName); writeData(data); res.json({ ok: true, removedSessions: sessionsBefore - data.sessions.length, removedTrials: trialsBefore - data.trials.length }); }); // POST /api/admin/clear-session — remove one session by session_id (admin only) // Body: { sessionId, deleteTrials: true|false } // deleteTrials=true → remove the session summary AND all its trial clicks // deleteTrials=false → remove only the session summary; keep the raw trial clicks app.post('/api/admin/clear-session', adminAuth, (req, res) => { const { sessionId, deleteTrials = true } = req.body; if (!sessionId) return res.status(400).json({ error: 'sessionId required' }); const data = readData(); const sessionsBefore = data.sessions.length; const trialsBefore = data.trials.length; data.sessions = data.sessions.filter(s => s.session_id !== sessionId); if (deleteTrials) { data.trials = data.trials.filter(t => t.session_id !== sessionId); } writeData(data); res.json({ ok: true, removedSessions: sessionsBefore - data.sessions.length, removedTrials: trialsBefore - data.trials.length }); }); app.listen(PORT, () => { console.log(`Microtubule Challenge → http://localhost:${PORT}`); console.log(` Drop images into: images/real/ and images/fake/`); console.log(` Export 2AFC data: http://localhost:${PORT}/api/export`); });