Add secure public driver training profiles
This commit is contained in:
201
server.js
201
server.js
@@ -7,13 +7,14 @@ const Database = require("better-sqlite3");
|
||||
|
||||
const PORT = Number(process.env.PORT || 8081);
|
||||
const HOST = process.env.HOST || "0.0.0.0";
|
||||
const DB_DIR = path.join(__dirname, "data");
|
||||
const DB_DIR = process.env.RC_TIMING_DATA_DIR ? path.resolve(process.env.RC_TIMING_DATA_DIR) : path.join(__dirname, "data");
|
||||
const DB_PATH = path.join(DB_DIR, "rc_timing.sqlite");
|
||||
const AMMC_CONFIG_PATH = path.join(DB_DIR, "ammc_config.json");
|
||||
const PID_FILE = process.env.RC_TIMING_PID_FILE || "";
|
||||
const SRC_DIR = path.join(__dirname, "src");
|
||||
const WATCHED_FILES = [
|
||||
path.join(__dirname, "index.html"),
|
||||
path.join(__dirname, "public-training.html"),
|
||||
...fs.readdirSync(SRC_DIR)
|
||||
.filter((fileName) => /\.(?:js|css)$/i.test(fileName))
|
||||
.map((fileName) => path.join(SRC_DIR, fileName)),
|
||||
@@ -280,8 +281,38 @@ app.get(["/public-overlay", "/public-overlay/:mode"], (req, res) => {
|
||||
res.sendFile(path.join(__dirname, "index.html"));
|
||||
});
|
||||
|
||||
app.get("/public-training/api/:shareToken", (req, res) => {
|
||||
try {
|
||||
const stored = readStoredAppState();
|
||||
const profile = stored ? buildPublicTrainingProfile(stored.state, String(req.params.shareToken || ""), stored.updatedAt) : null;
|
||||
if (!profile) {
|
||||
res.status(404).json({ error: "Profile not found" });
|
||||
return;
|
||||
}
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.json(profile);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/public-training/:shareToken", (req, res) => {
|
||||
try {
|
||||
const stored = readStoredAppState();
|
||||
if (!stored || !hasPublicTrainingShare(stored.state, String(req.params.shareToken || ""))) {
|
||||
res.status(404).type("text/plain").send("Profile not found");
|
||||
return;
|
||||
}
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.sendFile(path.join(__dirname, "public-training.html"));
|
||||
} catch {
|
||||
res.status(404).type("text/plain").send("Profile not found");
|
||||
}
|
||||
});
|
||||
|
||||
app.use(
|
||||
express.static(__dirname, {
|
||||
"/src",
|
||||
express.static(SRC_DIR, {
|
||||
setHeaders: (res, filePath) => {
|
||||
if (filePath.endsWith(".html") || filePath.endsWith(".js") || filePath.endsWith(".css")) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
@@ -290,6 +321,8 @@ app.use(
|
||||
})
|
||||
);
|
||||
|
||||
app.use("/docs", express.static(path.join(__dirname, "docs")));
|
||||
|
||||
app.get("*", (_req, res) => {
|
||||
res.sendFile(path.join(__dirname, "index.html"));
|
||||
});
|
||||
@@ -335,6 +368,170 @@ function initSchema() {
|
||||
`);
|
||||
}
|
||||
|
||||
function readStoredAppState() {
|
||||
const row = db.prepare("SELECT state_json, updated_at FROM app_state WHERE id = 1").get();
|
||||
if (!row?.state_json) return null;
|
||||
return { state: JSON.parse(row.state_json), updatedAt: row.updated_at };
|
||||
}
|
||||
|
||||
function findPublicTrainingShare(state, shareToken) {
|
||||
if (!shareToken || shareToken.length < 24) return null;
|
||||
const shares = Array.isArray(state?.training?.shares) ? state.training.shares : [];
|
||||
return shares.find((share) => share?.enabled !== false && String(share?.token || "") === shareToken) || null;
|
||||
}
|
||||
|
||||
function hasPublicTrainingShare(state, shareToken) {
|
||||
return Boolean(findPublicTrainingShare(state, shareToken));
|
||||
}
|
||||
|
||||
function buildPublicTrainingProfile(state, shareToken, updatedAt) {
|
||||
const share = findPublicTrainingShare(state, shareToken);
|
||||
if (!share) return null;
|
||||
const driverId = String(share.driverId || "");
|
||||
const drivers = Array.isArray(state?.drivers) ? state.drivers : [];
|
||||
const driver = drivers.find((item) => String(item?.id || "") === driverId);
|
||||
if (!driver) return null;
|
||||
|
||||
const classes = Array.isArray(state?.classes) ? state.classes : [];
|
||||
const driverClass = classes.find((item) => String(item?.id || "") === String(driver.classId || ""));
|
||||
const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : [];
|
||||
const driverLaps = ledger
|
||||
.filter((entry) => String(entry?.driverId || "") === driverId && Number(entry?.lapMs || 0) > 500)
|
||||
.map((entry) => ({
|
||||
timestamp: Math.max(0, Number(entry.timestamp || 0) || 0),
|
||||
lapMs: Math.max(0, Math.round(Number(entry.lapMs || 0) || 0)),
|
||||
sessionId: String(entry.sessionId || ""),
|
||||
}))
|
||||
.sort((left, right) => left.timestamp - right.timestamp);
|
||||
const stats = buildPublicTrainingStats(driverLaps);
|
||||
const goals = (Array.isArray(state?.training?.goals) ? state.training.goals : [])
|
||||
.filter((goal) => String(goal?.driverId || "") === driverId)
|
||||
.map((goal) => buildPublicTrainingGoal(goal, stats.totalLaps));
|
||||
const rankedTotals = getRankedTrainingTotals(ledger, new Set(drivers.map((item) => String(item?.id || "")).filter(Boolean)));
|
||||
const rankIndex = rankedTotals.findIndex(([id]) => id === driverId);
|
||||
const settings = state?.settings && typeof state.settings === "object" ? state.settings : {};
|
||||
|
||||
return {
|
||||
updatedAt: String(updatedAt || ""),
|
||||
profile: {
|
||||
name: String(driver.name || "Driver").trim(),
|
||||
brand: String(driver.brand || "").trim(),
|
||||
className: String(driverClass?.name || "").trim(),
|
||||
clubRank: rankIndex >= 0 ? rankIndex + 1 : 0,
|
||||
rankedDrivers: rankedTotals.length,
|
||||
},
|
||||
branding: {
|
||||
clubName: String(settings.clubName || "JMK RB RaceController").trim(),
|
||||
clubTagline: String(settings.clubTagline || "RC Timing System").trim(),
|
||||
logoDataUrl: normalizePublicLogoDataUrl(settings.logoDataUrl),
|
||||
},
|
||||
stats,
|
||||
nextMilestone: getPublicTrainingMilestone(stats.totalLaps),
|
||||
recommendation: getPublicTrainingRecommendation(stats),
|
||||
goals,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPublicTrainingGoal(goal, currentLaps) {
|
||||
const targetLaps = Math.max(1, Math.round(Number(goal?.targetLaps || 1) || 1));
|
||||
return {
|
||||
title: String(goal?.title || "").trim(),
|
||||
reward: String(goal?.reward || "").trim(),
|
||||
targetLaps,
|
||||
currentLaps,
|
||||
remainingLaps: Math.max(0, targetLaps - currentLaps),
|
||||
percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)),
|
||||
reached: currentLaps >= targetLaps,
|
||||
claimed: Boolean(goal?.claimedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function getRankedTrainingTotals(ledger, registeredDriverIds) {
|
||||
const totals = new Map();
|
||||
ledger.forEach((entry) => {
|
||||
const driverId = String(entry?.driverId || "");
|
||||
if (!driverId || !registeredDriverIds.has(driverId) || Number(entry?.lapMs || 0) <= 500) return;
|
||||
totals.set(driverId, (totals.get(driverId) || 0) + 1);
|
||||
});
|
||||
return [...totals.entries()].sort((left, right) => right[1] - left[1]);
|
||||
}
|
||||
|
||||
function buildPublicTrainingStats(laps) {
|
||||
const lapTimes = laps.map((lap) => lap.lapMs);
|
||||
const recent = lapTimes.slice(-30);
|
||||
const average = (values) => values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0;
|
||||
const sortedRecent = [...recent].sort((left, right) => left - right);
|
||||
const percentile = (ratio) => sortedRecent.length
|
||||
? sortedRecent[Math.min(sortedRecent.length - 1, Math.max(0, Math.round((sortedRecent.length - 1) * ratio)))]
|
||||
: 0;
|
||||
const recentAverageMs = average(recent);
|
||||
const daily = new Map();
|
||||
laps.forEach((lap) => {
|
||||
const date = new Date(lap.timestamp);
|
||||
const dateKey = Number.isNaN(date.getTime()) ? "unknown" : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
const day = daily.get(dateKey) || { dateKey, laps: 0, totalMs: 0, bestLapMs: 0, latestTimestamp: 0 };
|
||||
day.laps += 1;
|
||||
day.totalMs += lap.lapMs;
|
||||
day.bestLapMs = day.bestLapMs ? Math.min(day.bestLapMs, lap.lapMs) : lap.lapMs;
|
||||
day.latestTimestamp = Math.max(day.latestTimestamp, lap.timestamp);
|
||||
daily.set(dateKey, day);
|
||||
});
|
||||
const dailySummaries = [...daily.values()]
|
||||
.map((day) => ({
|
||||
dateKey: day.dateKey,
|
||||
laps: day.laps,
|
||||
bestLapMs: day.bestLapMs,
|
||||
latestTimestamp: day.latestTimestamp,
|
||||
averageLapMs: day.laps ? Math.round(day.totalMs / day.laps) : 0,
|
||||
}))
|
||||
.sort((left, right) => right.latestTimestamp - left.latestTimestamp)
|
||||
.slice(0, 30);
|
||||
const bestLapMs = lapTimes.length ? Math.min(...lapTimes) : 0;
|
||||
const controlTargetMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0;
|
||||
let controlledLapStreak = 0;
|
||||
for (let index = lapTimes.length - 1; index >= 0; index -= 1) {
|
||||
if (!controlTargetMs || lapTimes[index] > controlTargetMs) break;
|
||||
controlledLapStreak += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
totalLaps: laps.length,
|
||||
totalSessions: new Set(laps.map((lap) => lap.sessionId).filter(Boolean)).size,
|
||||
trainingDays: daily.size,
|
||||
bestLapMs,
|
||||
averageLapMs: average(lapTimes),
|
||||
recentAverageMs,
|
||||
recentLapCount: recent.length,
|
||||
consistencySpreadPct: recentAverageMs ? ((percentile(0.9) - percentile(0.1)) / recentAverageMs) * 100 : 0,
|
||||
controlledLapStreak,
|
||||
dailySummaries,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePublicLogoDataUrl(value) {
|
||||
const logo = String(value || "").trim();
|
||||
return logo.length <= 2_000_000 && /^data:image\/(?:png|jpeg|webp);base64,/i.test(logo) ? logo : "";
|
||||
}
|
||||
|
||||
function getPublicTrainingMilestone(currentLaps) {
|
||||
const presets = [100, 250, 500, 1000, 1500, 2500, 5000];
|
||||
const targetLaps = presets.find((target) => target > currentLaps) || (Math.floor(currentLaps / 1000) + 1) * 1000;
|
||||
return {
|
||||
currentLaps,
|
||||
targetLaps,
|
||||
remainingLaps: Math.max(0, targetLaps - currentLaps),
|
||||
percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)),
|
||||
};
|
||||
}
|
||||
|
||||
function getPublicTrainingRecommendation(stats) {
|
||||
if (stats.totalLaps < 10) return { level: "baseline", targetLapMs: 0 };
|
||||
if (stats.recentLapCount >= 10 && stats.consistencySpreadPct > 8) {
|
||||
return { level: "consistency", targetLapMs: Math.round(stats.bestLapMs * 1.08) };
|
||||
}
|
||||
return { level: "progression", targetLapMs: Math.round(stats.bestLapMs * 1.05) };
|
||||
}
|
||||
|
||||
function normalizePdfExportPayload(input = {}) {
|
||||
const title = String(input.title || "JMK RB Live Event").trim();
|
||||
const subtitle = String(input.subtitle || "").trim();
|
||||
|
||||
Reference in New Issue
Block a user