DiffMT / server.js
Koddenbrock's picture
add admin dashboard for viewing DiffMT results
1ba3759
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`);
});