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