Spaces:
Running
Running
File size: 6,889 Bytes
944af38 4812e1d cdbf412 7d9bf9a cdbf412 944af38 4812e1d 944af38 4812e1d 944af38 461fe97 944af38 461fe97 944af38 461fe97 944af38 461fe97 944af38 461fe97 944af38 1ba3759 6895b85 cdbf412 944af38 fdfa289 cdbf412 fdfa289 64877f4 645c1d8 64877f4 645c1d8 64877f4 645c1d8 944af38 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 | 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`);
});
|