Refactor navigation and add authentication features
- Updated navigation links across guess.html, index.html, lists.html, players.html, roulette.html, rules.html, and run.html for consistency and clarity. - Enhanced index.html with a sign-up button for user registration. - Modified lists.html to improve user guidance on video watching and added a "My %" column for tracking progress. - Introduced login.html and signup.html for user authentication, including form validation and status messages. - Implemented server-side user authentication in server.js, including user registration, login, and session management. - Added user data handling for roulette progress and saved runs. - Created a .gitignore file to exclude sensitive user data files from version control. - Updated styles.css to accommodate new UI elements for authentication and improved layout.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# Server-side auth and user data (local only)
|
||||
server/users.json
|
||||
server/sessions.json
|
||||
server/userdata.json
|
||||
+3
-3
@@ -11,12 +11,12 @@
|
||||
<h1>Guess The Rank</h1>
|
||||
<nav>
|
||||
<a href="index.html">Home</a>
|
||||
<a href="roulette.html">Roulette</a>
|
||||
<a href="guess.html">Guess Game</a>
|
||||
<a href="players.html">Players</a>
|
||||
<a href="lists.html">Lists</a>
|
||||
<a href="run.html">Submit Run</a>
|
||||
<a href="players.html">Players</a>
|
||||
<a href="rules.html">Rules</a>
|
||||
<a href="admelist.html">Admin</a>
|
||||
<a href="roulette.html">Roulette</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
|
||||
+2
-1
@@ -12,13 +12,13 @@
|
||||
<header>
|
||||
<h1>FEDL - Ranked Demon List</h1>
|
||||
<nav>
|
||||
<a href="index.html">Home</a>
|
||||
<a href="guess.html">Guess Game</a>
|
||||
<a href="players.html">Players</a>
|
||||
<a href="lists.html">Lists</a>
|
||||
<a href="run.html">Submit Run</a>
|
||||
<a href="rules.html">Rules</a>
|
||||
<a href="roulette.html">Roulette</a>
|
||||
<a href="admelist.html">Admin</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
@@ -30,6 +30,7 @@
|
||||
|
||||
<div class="hero-actions">
|
||||
<a class="btn" href="lists.html">Open The List</a>
|
||||
<a class="btn" href="signup.html">Sign up</a>
|
||||
<a class="btn ghost-btn" href="guess.html">Play Guess Game</a>
|
||||
<a class="btn ghost-btn" href="run.html">Submit A Run</a>
|
||||
<a class="btn ghost-btn" href="rules.html">Submission Rules</a>
|
||||
|
||||
+3
-3
@@ -15,10 +15,10 @@
|
||||
<a href="index.html">Home</a>
|
||||
<a href="guess.html">Guess Game</a>
|
||||
<a href="players.html">Players</a>
|
||||
<a href="lists.html">Lists</a>
|
||||
<a href="run.html">Submit Run</a>
|
||||
<a href="rules.html">Rules</a>
|
||||
<a href="roulette.html">Roulette</a>
|
||||
<a href="admelist.html">Admin</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="layout">
|
||||
@@ -63,13 +63,13 @@
|
||||
<p class="status-label">List Table</p>
|
||||
<h3>Current placements</h3>
|
||||
</div>
|
||||
<p class="muted table-hint">Click <strong>Open</strong> on any row to watch the linked run.</p>
|
||||
<p class="muted table-hint">Click <strong>Open</strong> to watch the video. With a profile from Roulette, use <strong>My %</strong> to track progress per level (this device only).</p>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="levels-table">
|
||||
<thead>
|
||||
<tr><th style="width:80px">#</th><th>Title</th><th style="width:140px">Action</th></tr>
|
||||
<tr><th style="width:80px">#</th><th>Title</th><th style="width:100px">My %</th><th style="width:140px">Action</th></tr>
|
||||
</thead>
|
||||
<tbody id="list-area"></tbody>
|
||||
</table>
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Log in — GD fedl</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body data-page="login">
|
||||
<header>
|
||||
<h1>Log in</h1>
|
||||
<nav>
|
||||
<a href="index.html">Home</a>
|
||||
<a href="signup.html">Sign up</a>
|
||||
<a href="lists.html">Lists</a>
|
||||
<a href="roulette.html">Roulette</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="auth-main">
|
||||
<section class="panel auth-panel">
|
||||
<p class="hero-kicker">FEDL account</p>
|
||||
<h2>Welcome back</h2>
|
||||
<p class="muted">Sign in to load your saved roulette pick and per-level progress from the server.</p>
|
||||
<form id="login-form" class="auth-form">
|
||||
<label class="admin-password-field" for="login-username">
|
||||
<span>Username</span>
|
||||
<input id="login-username" name="username" type="text" autocomplete="username" required />
|
||||
</label>
|
||||
<label class="admin-password-field" for="login-password">
|
||||
<span>Password</span>
|
||||
<input id="login-password" name="password" type="password" autocomplete="current-password" required />
|
||||
</label>
|
||||
<p id="login-status" class="muted" role="status"></p>
|
||||
<div class="auth-actions">
|
||||
<button type="submit" class="btn" id="login-submit">Log in</button>
|
||||
<a class="text-link" href="signup.html">Need an account? Sign up</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -12,11 +12,11 @@
|
||||
<nav>
|
||||
<a href="index.html">Home</a>
|
||||
<a href="guess.html">Guess Game</a>
|
||||
<a href="players.html">Players</a>
|
||||
<a href="lists.html">Lists</a>
|
||||
<a href="run.html">Submit Run</a>
|
||||
<a href="rules.html">Rules</a>
|
||||
<a href="roulette.html">Roulette</a>
|
||||
<a href="admelist.html">Admin</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="layout">
|
||||
|
||||
+54
-2
@@ -12,15 +12,57 @@
|
||||
<nav>
|
||||
<a href="index.html">Home</a>
|
||||
<a href="guess.html">Guess Game</a>
|
||||
<a href="players.html">Players</a>
|
||||
<a href="lists.html">Lists</a>
|
||||
<a href="run.html">Submit Run</a>
|
||||
<a href="players.html">Players</a>
|
||||
<a href="rules.html">Rules</a>
|
||||
<a href="admelist.html">Admin</a>
|
||||
<a href="roulette.html">Roulette</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<section class="roulette-shell">
|
||||
<div class="roulette-main-stack">
|
||||
<section class="panel roulette-account-panel">
|
||||
<p class="roulette-kicker">Your profile</p>
|
||||
<h2 class="roulette-account-heading">Save roulette & progress</h2>
|
||||
<p class="muted small roulette-account-intro">Profiles are stored only in this browser. Each profile remembers your last roulette demon and a progress % per level (also shown on the list page).</p>
|
||||
<p class="muted small roulette-login-sync-hint" id="roulette-login-sync-hint" hidden>
|
||||
<a href="login.html">Log in</a> or <a href="signup.html">sign up</a> to sync roulette, save slots, and % across browsers.
|
||||
</p>
|
||||
<div class="roulette-slots-block" id="roulette-slots-block">
|
||||
<p class="roulette-slots-heading">Roulette save slots</p>
|
||||
<p class="muted small" id="roulette-slots-hint">Use three slots to park different demons. Requires an active profile or a signed-in account.</p>
|
||||
<div class="roulette-slot-rows">
|
||||
<div class="roulette-slot-row">
|
||||
<span class="roulette-slot-title">Slot 1</span>
|
||||
<span class="roulette-slot-label muted" id="roulette-slot-label-1">Empty</span>
|
||||
<button type="button" class="btn ghost-btn small-btn" id="roulette-slot-save-1">Save</button>
|
||||
<button type="button" class="btn ghost-btn small-btn" id="roulette-slot-load-1">Load</button>
|
||||
</div>
|
||||
<div class="roulette-slot-row">
|
||||
<span class="roulette-slot-title">Slot 2</span>
|
||||
<span class="roulette-slot-label muted" id="roulette-slot-label-2">Empty</span>
|
||||
<button type="button" class="btn ghost-btn small-btn" id="roulette-slot-save-2">Save</button>
|
||||
<button type="button" class="btn ghost-btn small-btn" id="roulette-slot-load-2">Load</button>
|
||||
</div>
|
||||
<div class="roulette-slot-row">
|
||||
<span class="roulette-slot-title">Slot 3</span>
|
||||
<span class="roulette-slot-label muted" id="roulette-slot-label-3">Empty</span>
|
||||
<button type="button" class="btn ghost-btn small-btn" id="roulette-slot-save-3">Save</button>
|
||||
<button type="button" class="btn ghost-btn small-btn" id="roulette-slot-load-3">Load</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="roulette-account-controls">
|
||||
<label class="roulette-account-label" for="roulette-account-select">Active profile</label>
|
||||
<select id="roulette-account-select" class="roulette-account-select"></select>
|
||||
<div class="roulette-account-create-row">
|
||||
<input id="roulette-account-new" type="text" placeholder="New profile name" autocomplete="username" />
|
||||
<button type="button" class="btn ghost-btn" id="roulette-account-create">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn ghost-btn roulette-restore-btn" id="roulette-restore" hidden>Restore last spin</button>
|
||||
</section>
|
||||
<section class="panel roulette-panel">
|
||||
<div class="roulette-head">
|
||||
<div>
|
||||
@@ -37,9 +79,19 @@
|
||||
<p class="roulette-rank" id="roulette-rank">Rank: -</p>
|
||||
<p class="roulette-meta" id="roulette-level-id">Level ID: -</p>
|
||||
<p class="roulette-note muted" id="roulette-note">Level IDs come from your local file first, then API lookup if needed.</p>
|
||||
<div class="roulette-progress-block" id="roulette-progress-row" hidden>
|
||||
<label class="roulette-progress-label" for="roulette-percent">Your % on this level</label>
|
||||
<div class="roulette-progress-input-row">
|
||||
<input id="roulette-percent" type="text" inputmode="decimal" placeholder="e.g. 47" autocomplete="off" />
|
||||
<span class="muted">%</span>
|
||||
<button type="button" class="btn small-btn" id="roulette-percent-submit">Submit %</button>
|
||||
</div>
|
||||
<p id="roulette-percent-hint" class="muted small roulette-percent-hint"></p>
|
||||
</div>
|
||||
<a class="btn roulette-open" id="roulette-open" href="#" target="_blank" rel="noopener noreferrer" hidden>View Video</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="panel roulette-rules-card">
|
||||
<p class="roulette-kicker">How It Works</p>
|
||||
|
||||
+2
-2
@@ -12,11 +12,11 @@
|
||||
<nav>
|
||||
<a href="index.html">Home</a>
|
||||
<a href="guess.html">Guess Game</a>
|
||||
<a href="players.html">Players</a>
|
||||
<a href="lists.html">Lists</a>
|
||||
<a href="run.html">Submit Run</a>
|
||||
<a href="players.html">Players</a>
|
||||
<a href="rules.html">Rules</a>
|
||||
<a href="roulette.html">Roulette</a>
|
||||
<a href="admelist.html">Admin</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
<nav>
|
||||
<a href="index.html">Home</a>
|
||||
<a href="guess.html">Guess Game</a>
|
||||
<a href="players.html">Players</a>
|
||||
<a href="lists.html">Lists</a>
|
||||
<a href="run.html">Submit Run</a>
|
||||
<a href="rules.html">Rules</a>
|
||||
<a href="roulette.html">Roulette</a>
|
||||
<a href="admelist.html">Admin</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
@@ -25,7 +26,7 @@
|
||||
<div>
|
||||
<p class="hero-kicker">FEDL Submission</p>
|
||||
<h2>Send in a run</h2>
|
||||
<p class="muted run-intro">Submit your completion video, attach raw footage if you have it hosted, and leave notes for the moderators. Required fields are marked below.</p>
|
||||
<p class="muted run-intro">Submit your completion video, attach raw footage if you have it hosted, and leave notes for the moderators. If you <a href="login.html">log in</a>, you can save multiple runs to your account (drafts) and queue submissions will show your username to moderators.</p>
|
||||
</div>
|
||||
<div class="run-head-badge">
|
||||
<strong>Live queue</strong>
|
||||
@@ -67,6 +68,7 @@
|
||||
|
||||
<div class="run-actions">
|
||||
<button id="run-submit" type="submit" class="btn">Submit Run</button>
|
||||
<button type="button" id="run-save-account" class="btn ghost-btn" hidden>Save to my account</button>
|
||||
<p id="run-form-status" class="muted">The server needs to be running for live submissions.</p>
|
||||
</div>
|
||||
</form>
|
||||
@@ -92,6 +94,17 @@
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="panel run-my-runs-panel" id="run-my-runs-section" hidden>
|
||||
<div class="submission-header run-my-runs-head">
|
||||
<div>
|
||||
<p class="hero-kicker">Your account</p>
|
||||
<h3>Runs saved to your account</h3>
|
||||
<p class="muted">Keep multiple drafts here while you work on links or notes. They sync when you are signed in.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="run-saved-runs-list" class="submission-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel submission-panel">
|
||||
<div class="table-header submission-header">
|
||||
<div>
|
||||
|
||||
+350
-1
@@ -1,11 +1,15 @@
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { URL } = require('url');
|
||||
|
||||
const appRoot = path.resolve(__dirname, '..');
|
||||
const dataPath = path.join(__dirname, 'data.txt');
|
||||
const runsPath = path.join(__dirname, 'runs.json');
|
||||
const usersPath = path.join(__dirname, 'users.json');
|
||||
const sessionsPath = path.join(__dirname, 'sessions.json');
|
||||
const userDataPath = path.join(__dirname, 'userdata.json');
|
||||
const port = Number(process.env.PORT) || 8090;
|
||||
const host = process.env.HOST || '127.0.0.1';
|
||||
const BASE = '/fedl';
|
||||
@@ -107,6 +111,136 @@ function writeRuns(runs) {
|
||||
fs.writeFileSync(runsPath, `${JSON.stringify(runs, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function ensureUsersFile() {
|
||||
if (!fs.existsSync(usersPath)) {
|
||||
fs.writeFileSync(usersPath, '[]\n', 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSessionsFile() {
|
||||
if (!fs.existsSync(sessionsPath)) {
|
||||
fs.writeFileSync(sessionsPath, '{}\n', 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureUserDataFile() {
|
||||
if (!fs.existsSync(userDataPath)) {
|
||||
fs.writeFileSync(userDataPath, '{}\n', 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function readUsers() {
|
||||
ensureUsersFile();
|
||||
const raw = fs.readFileSync(usersPath, 'utf8');
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
|
||||
function writeUsers(users) {
|
||||
fs.writeFileSync(usersPath, `${JSON.stringify(users, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function readSessionsRaw() {
|
||||
ensureSessionsFile();
|
||||
const raw = fs.readFileSync(sessionsPath, 'utf8');
|
||||
const parsed = JSON.parse(raw || '{}');
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
}
|
||||
|
||||
function writeSessions(sessions) {
|
||||
fs.writeFileSync(sessionsPath, `${JSON.stringify(sessions, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function cleanSessions(sessions) {
|
||||
const now = Date.now();
|
||||
const out = {};
|
||||
Object.keys(sessions).forEach(token => {
|
||||
const s = sessions[token];
|
||||
if (s && s.expiresAt && new Date(s.expiresAt).getTime() > now) {
|
||||
out[token] = s;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function readSessions() {
|
||||
return cleanSessions(readSessionsRaw());
|
||||
}
|
||||
|
||||
function createSession(userId, username) {
|
||||
const sessions = readSessions();
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
|
||||
sessions[token] = { userId, username, expiresAt };
|
||||
writeSessions(sessions);
|
||||
return token;
|
||||
}
|
||||
|
||||
function findSession(token) {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const sessions = readSessions();
|
||||
const s = sessions[token];
|
||||
if (!s || new Date(s.expiresAt).getTime() <= Date.now()) {
|
||||
return null;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function deleteSession(token) {
|
||||
const sessions = readSessionsRaw();
|
||||
delete sessions[token];
|
||||
writeSessions(cleanSessions(sessions));
|
||||
}
|
||||
|
||||
function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
|
||||
return { salt, hash };
|
||||
}
|
||||
|
||||
function verifyPassword(password, salt, hashHex) {
|
||||
try {
|
||||
const h = crypto.scryptSync(password, salt, 64).toString('hex');
|
||||
const a = Buffer.from(h, 'hex');
|
||||
const b = Buffer.from(hashHex, 'hex');
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUsername(u) {
|
||||
return String(u || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function usernameOk(u) {
|
||||
return /^[a-z0-9_]{3,24}$/.test(u);
|
||||
}
|
||||
|
||||
function getBearerToken(req) {
|
||||
const h = String(req.headers.authorization || '');
|
||||
const m = h.match(/^Bearer\s+(\S+)/i);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function readUserDataMap() {
|
||||
ensureUserDataFile();
|
||||
const raw = fs.readFileSync(userDataPath, 'utf8');
|
||||
const parsed = JSON.parse(raw || '{}');
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
}
|
||||
|
||||
function writeUserDataMap(map) {
|
||||
fs.writeFileSync(userDataPath, `${JSON.stringify(map, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -473,10 +607,73 @@ function normalizeRun(payload, existingRun) {
|
||||
reviewedBy: String(payload.reviewedBy || existingRun?.reviewedBy || '').trim(),
|
||||
reviewNotes: String(payload.reviewNotes || existingRun?.reviewNotes || '').trim(),
|
||||
submittedAt: existingRun?.submittedAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
updatedAt: new Date().toISOString(),
|
||||
accountUserId: existingRun?.accountUserId || '',
|
||||
accountUsername: existingRun?.accountUsername || ''
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_SAVED_RUNS_PER_USER = 48;
|
||||
|
||||
function sanitizeSavedRuns(raw) {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
const out = [];
|
||||
for (let i = 0; i < raw.length && out.length < MAX_SAVED_RUNS_PER_USER; i += 1) {
|
||||
const item = raw[i];
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const playerName = String(item.playerName || '').trim().slice(0, 120);
|
||||
const levelTitle = String(item.levelTitle || '').trim().slice(0, 280);
|
||||
if (!playerName || !levelTitle) continue;
|
||||
out.push({
|
||||
id: String(item.id || `sv_${Date.now().toString(36)}_${i}_${Math.random().toString(36).slice(2, 8)}`).slice(0, 96),
|
||||
playerName,
|
||||
levelTitle,
|
||||
videoUrl: String(item.videoUrl || '').trim().slice(0, 2048),
|
||||
percent: String(item.percent != null ? item.percent : '100').trim().slice(0, 12) || '100',
|
||||
rawFootageUrl: String(item.rawFootageUrl || '').trim().slice(0, 2048),
|
||||
notes: String(item.notes || '').trim().slice(0, 8000),
|
||||
savedAt: String(item.savedAt || new Date().toISOString()).slice(0, 48)
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const ROULETTE_SLOT_KEYS = ['1', '2', '3'];
|
||||
|
||||
function emptyRouletteSlots() {
|
||||
return { '1': null, '2': null, '3': null };
|
||||
}
|
||||
|
||||
function sanitizeRouletteSlots(raw) {
|
||||
const out = emptyRouletteSlots();
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return out;
|
||||
}
|
||||
for (const k of ROULETTE_SLOT_KEYS) {
|
||||
const v = raw[k];
|
||||
if (v == null || typeof v !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const title = String(v.title || '').trim().slice(0, 280);
|
||||
if (!title) {
|
||||
continue;
|
||||
}
|
||||
out[k] = {
|
||||
title,
|
||||
position: String(v.position || '').trim().slice(0, 32),
|
||||
level: String(v.level || '').trim().slice(0, 120),
|
||||
url: String(v.url || '').trim().slice(0, 2048),
|
||||
levelId: String(v.levelId || '').trim().slice(0, 64),
|
||||
noteSource: v.noteSource === 'api' ? 'api' : 'file',
|
||||
percent: String(v.percent != null ? v.percent : '').trim().slice(0, 12),
|
||||
savedAt: String(v.savedAt || new Date().toISOString()).slice(0, 48)
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function serveFile(reqPath, res) {
|
||||
let filePath = path.join(appRoot, reqPath === '/' ? 'index.html' : reqPath.slice(1));
|
||||
filePath = path.normalize(filePath);
|
||||
@@ -521,6 +718,153 @@ const server = http.createServer((req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/auth/signup') {
|
||||
let body = '';
|
||||
req.on('data', chunk => {
|
||||
body += chunk;
|
||||
if (body.length > 65536) req.destroy();
|
||||
});
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const payload = JSON.parse(body || '{}');
|
||||
const username = normalizeUsername(payload.username);
|
||||
const password = String(payload.password || '');
|
||||
if (!usernameOk(username)) {
|
||||
sendJson(res, 400, {
|
||||
error: 'Username must be 3-24 characters: lowercase letters, numbers, or underscore.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
sendJson(res, 400, { error: 'Password must be at least 8 characters.' });
|
||||
return;
|
||||
}
|
||||
const users = readUsers();
|
||||
if (users.some(u => u.username === username)) {
|
||||
sendJson(res, 409, { error: 'That username is already taken.' });
|
||||
return;
|
||||
}
|
||||
const { salt, hash } = hashPassword(password);
|
||||
const id = `usr_${Date.now().toString(36)}${crypto.randomBytes(4).toString('hex')}`;
|
||||
users.push({
|
||||
id,
|
||||
username,
|
||||
passwordHash: hash,
|
||||
salt,
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
writeUsers(users);
|
||||
const token = createSession(id, username);
|
||||
sendJson(res, 201, { ok: true, token, userId: id, username });
|
||||
} catch (error) {
|
||||
sendJson(res, 400, { error: 'Invalid signup request' });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/auth/login') {
|
||||
let body = '';
|
||||
req.on('data', chunk => {
|
||||
body += chunk;
|
||||
if (body.length > 65536) req.destroy();
|
||||
});
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const payload = JSON.parse(body || '{}');
|
||||
const username = normalizeUsername(payload.username);
|
||||
const password = String(payload.password || '');
|
||||
const users = readUsers();
|
||||
const user = users.find(u => u.username === username);
|
||||
if (!user || !verifyPassword(password, user.salt, user.passwordHash)) {
|
||||
sendJson(res, 401, { error: 'Invalid username or password.' });
|
||||
return;
|
||||
}
|
||||
const token = createSession(user.id, user.username);
|
||||
sendJson(res, 200, { ok: true, token, userId: user.id, username: user.username });
|
||||
} catch (error) {
|
||||
sendJson(res, 400, { error: 'Invalid login request' });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/auth/logout') {
|
||||
const token = getBearerToken(req);
|
||||
if (token) {
|
||||
deleteSession(token);
|
||||
}
|
||||
sendJson(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && pathname === '/api/auth/me') {
|
||||
const token = getBearerToken(req);
|
||||
const sess = findSession(token);
|
||||
if (!sess) {
|
||||
sendJson(res, 401, { error: 'Not signed in' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, { userId: sess.userId, username: sess.username });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && pathname === '/api/user/state') {
|
||||
const token = getBearerToken(req);
|
||||
const sess = findSession(token);
|
||||
if (!sess) {
|
||||
sendJson(res, 401, { error: 'Not signed in' });
|
||||
return;
|
||||
}
|
||||
const map = readUserDataMap();
|
||||
const row = map[sess.userId] || {};
|
||||
const data = {
|
||||
roulettePick: row.roulettePick != null ? row.roulettePick : null,
|
||||
levelPercents: row.levelPercents && typeof row.levelPercents === 'object' ? row.levelPercents : {},
|
||||
savedRuns: Array.isArray(row.savedRuns) ? row.savedRuns : [],
|
||||
rouletteSlots: sanitizeRouletteSlots(row.rouletteSlots)
|
||||
};
|
||||
sendJson(res, 200, { data });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'PUT' && pathname === '/api/user/state') {
|
||||
const token = getBearerToken(req);
|
||||
const sess = findSession(token);
|
||||
if (!sess) {
|
||||
sendJson(res, 401, { error: 'Not signed in' });
|
||||
return;
|
||||
}
|
||||
let body = '';
|
||||
req.on('data', chunk => {
|
||||
body += chunk;
|
||||
if (body.length > 2 * 1024 * 1024) req.destroy();
|
||||
});
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const payload = JSON.parse(body || '{}');
|
||||
const incoming = payload.data;
|
||||
if (!incoming || typeof incoming !== 'object') {
|
||||
sendJson(res, 400, { error: 'A "data" object is required' });
|
||||
return;
|
||||
}
|
||||
const map = readUserDataMap();
|
||||
map[sess.userId] = {
|
||||
roulettePick: incoming.roulettePick != null ? incoming.roulettePick : null,
|
||||
levelPercents:
|
||||
incoming.levelPercents && typeof incoming.levelPercents === 'object' ? incoming.levelPercents : {},
|
||||
savedRuns: sanitizeSavedRuns(incoming.savedRuns),
|
||||
rouletteSlots: sanitizeRouletteSlots(incoming.rouletteSlots)
|
||||
};
|
||||
writeUserDataMap(map);
|
||||
sendJson(res, 200, { ok: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 400, { error: 'Invalid payload' });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && pathname === '/api/list') {
|
||||
try {
|
||||
const text = readDataText();
|
||||
@@ -586,6 +930,11 @@ const server = http.createServer((req, res) => {
|
||||
try {
|
||||
const payload = JSON.parse(body || '{}');
|
||||
const nextRun = normalizeRun(payload);
|
||||
const sess = findSession(getBearerToken(req));
|
||||
if (sess) {
|
||||
nextRun.accountUserId = sess.userId;
|
||||
nextRun.accountUsername = sess.username;
|
||||
}
|
||||
if (!nextRun.playerName || !nextRun.levelTitle || !nextRun.videoUrl || !nextRun.percent) {
|
||||
sendJson(res, 400, { error: 'playerName, levelTitle, videoUrl, and percent are required' });
|
||||
return;
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Sign up — GD fedl</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body data-page="signup">
|
||||
<header>
|
||||
<h1>Sign up</h1>
|
||||
<nav>
|
||||
<a href="index.html">Home</a>
|
||||
<a href="login.html">Log in</a>
|
||||
<a href="lists.html">Lists</a>
|
||||
<a href="roulette.html">Roulette</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="auth-main">
|
||||
<section class="panel auth-panel">
|
||||
<p class="hero-kicker">FEDL account</p>
|
||||
<h2>Create an account</h2>
|
||||
<p class="muted">Username and password are stored on your FEDL server. Roulette progress and list % sync to the server when you are signed in.</p>
|
||||
<form id="signup-form" class="auth-form">
|
||||
<label class="admin-password-field" for="signup-username">
|
||||
<span>Username</span>
|
||||
<input id="signup-username" name="username" type="text" autocomplete="username" required minlength="3" maxlength="24" pattern="[a-z0-9_]{3,24}" placeholder="letters, numbers, underscore" />
|
||||
</label>
|
||||
<p class="muted small auth-hint">3–24 characters, lowercase letters, numbers, or underscore only.</p>
|
||||
<label class="admin-password-field" for="signup-password">
|
||||
<span>Password</span>
|
||||
<input id="signup-password" name="password" type="password" autocomplete="new-password" required minlength="8" placeholder="At least 8 characters" />
|
||||
</label>
|
||||
<label class="admin-password-field" for="signup-password2">
|
||||
<span>Confirm password</span>
|
||||
<input id="signup-password2" name="password2" type="password" autocomplete="new-password" required minlength="8" />
|
||||
</label>
|
||||
<p id="signup-status" class="muted" role="status"></p>
|
||||
<div class="auth-actions">
|
||||
<button type="submit" class="btn" id="signup-submit">Create account</button>
|
||||
<a class="text-link" href="login.html">Already have an account? Log in</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+43
-1
@@ -31,6 +31,8 @@ table.levels-table tr:hover td{background:rgba(255,255,255,0.01)}
|
||||
.btn:hover{transform:translateY(-1px);box-shadow:0 10px 24px rgba(92,197,255,0.22)}
|
||||
.muted{color:var(--muted);font-size:0.9rem}
|
||||
.error-text{color:#ff9f9f}
|
||||
.success-text{color:#8cf0ae!important}
|
||||
header nav a.btn{margin-left:14px;text-decoration:none;vertical-align:middle}
|
||||
.small-btn{padding:8px 12px}
|
||||
.danger-btn{background:#ff7b7b;color:#2a0909}
|
||||
.admin-shell{padding:18px 20px}
|
||||
@@ -184,6 +186,34 @@ table.levels-table .btn{padding:9px 14px}
|
||||
/* small helpers reused */
|
||||
.small{font-size:0.9rem}
|
||||
.roulette-shell{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr);gap:18px;align-items:start}
|
||||
.roulette-main-stack{display:grid;gap:18px;min-width:0}
|
||||
.roulette-account-panel{padding:22px 24px;border-radius:28px;background:linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02));border:1px solid rgba(255,255,255,0.08)}
|
||||
.roulette-account-heading{margin:0 0 8px 0;font-size:1.35rem}
|
||||
.roulette-account-intro{margin:0 0 14px;line-height:1.55}
|
||||
.roulette-account-controls{display:grid;gap:10px}
|
||||
.roulette-account-label{font-size:0.88rem;font-weight:700;color:var(--muted)}
|
||||
.roulette-account-select{width:100%;max-width:320px;padding:10px 12px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:var(--text);font:inherit}
|
||||
.roulette-account-create-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
|
||||
.roulette-account-create-row input{flex:1;min-width:160px;padding:10px 12px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:var(--text);font:inherit}
|
||||
.roulette-restore-btn{margin-top:12px}
|
||||
.roulette-login-sync-hint{margin:0 0 14px;line-height:1.55}
|
||||
.roulette-login-sync-hint a{color:var(--accent)}
|
||||
.roulette-slots-block{margin:14px 0;padding:14px 0;border-top:1px solid rgba(255,255,255,0.08);border-bottom:1px solid rgba(255,255,255,0.08)}
|
||||
.roulette-slots-heading{margin:0 0 6px 0;font-size:0.95rem;font-weight:800;color:#eaf2fb}
|
||||
.roulette-slot-row{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-top:10px}
|
||||
.roulette-slot-title{min-width:52px;font-weight:700;font-size:0.88rem;color:var(--muted)}
|
||||
.roulette-slot-label{flex:1;min-width:140px;font-size:0.88rem}
|
||||
.roulette-progress-block{margin-top:16px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.08)}
|
||||
.roulette-progress-label{display:block;font-size:0.88rem;font-weight:700;margin-bottom:8px;color:#dce8f5}
|
||||
.roulette-progress-input-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
|
||||
.roulette-progress-input-row input{width:100px;padding:10px 12px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:var(--text);font:inherit}
|
||||
.roulette-percent-hint{margin:10px 0 0;line-height:1.55;max-width:52ch}
|
||||
.modal-account-progress{margin:16px 0;padding:14px 16px;border-radius:14px;background:rgba(92,197,255,0.08);border:1px solid rgba(92,197,255,0.15)}
|
||||
.modal-account-label{margin:0 0 8px 0;font-size:0.9rem;font-weight:700}
|
||||
.modal-account-row{display:flex;gap:10px;align-items:center}
|
||||
.modal-account-pct-input{width:100px;padding:8px 10px;border-radius:10px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.05);color:var(--text);font:inherit}
|
||||
.list-my-progress-cell{vertical-align:middle}
|
||||
.list-progress-input{width:100%;max-width:88px;padding:8px 10px;border-radius:10px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:var(--text);font:inherit;text-align:center}
|
||||
.roulette-main-column{display:grid;gap:18px}
|
||||
.roulette-rules{margin-bottom:18px}
|
||||
.roulette-panel{margin-top:0;padding:28px;border-radius:28px;background:linear-gradient(135deg,rgba(92,197,255,0.14),rgba(7,19,38,0.96));border:1px solid rgba(92,197,255,0.18)}
|
||||
@@ -233,6 +263,9 @@ table.levels-table .btn{padding:9px 14px}
|
||||
.submission-panel{margin-top:18px;padding:22px 22px 16px;border-radius:26px;background:linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02));border:1px solid rgba(255,255,255,0.06)}
|
||||
.submission-header{margin-bottom:18px}
|
||||
.submission-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px}
|
||||
.run-my-runs-panel{margin-top:8px}
|
||||
.run-my-runs-head{margin-bottom:14px}
|
||||
.run-saved-card-actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px}
|
||||
.submission-card{padding:18px;border-radius:18px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08)}
|
||||
.submission-card-top{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||||
.submission-card strong{font-size:1.05rem}
|
||||
@@ -284,6 +317,15 @@ linear-gradient(180deg,rgba(8,18,32,0.98),rgba(7,19,38,0.94))}
|
||||
.error-actions{display:flex;flex-wrap:wrap;gap:12px;margin-top:24px}
|
||||
.error-actions .btn{font-weight:700;text-decoration:none}
|
||||
.ghost-btn{background:transparent;color:var(--text);border:1px solid rgba(255,255,255,0.14)}
|
||||
.auth-main{max-width:520px;margin:0 auto;padding:28px 20px 48px}
|
||||
.auth-panel{padding:28px 26px}
|
||||
.auth-panel h2{margin:0 0 8px 0;font-size:1.45rem}
|
||||
.auth-form{display:grid;gap:16px;margin-top:18px}
|
||||
.auth-form .auth-hint{margin:-6px 0 0 0;font-size:.82rem}
|
||||
.auth-actions{display:flex;flex-wrap:wrap;align-items:center;gap:14px;margin-top:8px}
|
||||
header nav .fedl-auth-nav{margin-left:14px;display:inline-flex;align-items:center;flex-wrap:wrap;gap:8px 10px;vertical-align:middle}
|
||||
header nav .fedl-auth-nav a{color:var(--muted)}
|
||||
header nav .fedl-auth-nav a:hover{color:var(--text)}
|
||||
.error-side{display:flex;flex-direction:column;justify-content:space-between;gap:18px;padding:34px 30px;background:linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))}
|
||||
.error-side h3{margin:0 0 10px 0;font-size:1rem;letter-spacing:0.04em;text-transform:uppercase;color:#eef6ff}
|
||||
.error-list{margin:0;padding:0;list-style:none}
|
||||
@@ -300,4 +342,4 @@ linear-gradient(180deg,rgba(8,18,32,0.98),rgba(7,19,38,0.94))}
|
||||
@media(max-width:900px){.layout{flex-direction:column}.sidebar{width:100%}.sidebar .panel{height:auto}.video-modal iframe{height:320px}.modal-runs-head,.modal-run-card{grid-template-columns:1fr}.home-hero{grid-template-columns:1fr;min-height:auto}.home-copy,.hero-stage{padding:24px 22px}.hero-stats,.home-section-grid,.spotlight-grid{grid-template-columns:1fr}.spotlight-panel{grid-template-columns:1fr;padding:24px 22px}.list-hero-panel,.table-header,.search-row,.roulette-head,.guess-controls{flex-direction:column;align-items:flex-start}.control-select{max-width:none;width:100%}.roulette-shell{grid-template-columns:1fr}.discord-panel{flex-direction:column;align-items:flex-start;padding:22px 20px}.discord-panel h2{font-size:1.55rem}.discord-actions{width:100%;justify-content:flex-start}.rules-view header{padding:16px 18px}.rules-view header h1{font-size:1.55rem}.rules-page{padding:24px 20px}.rules-page h2{font-size:1.65rem}.guess-controls input,.guess-controls .btn{width:100%}}
|
||||
@media(max-width:900px){.error-grid{grid-template-columns:1fr}.error-copy,.error-side{padding:24px 20px}.error-copy h2{font-size:2.5rem}}
|
||||
@media(max-width:900px){.admin-overview-panel,.admin-toolbar{flex-direction:column;align-items:stretch}.admin-actions{flex-direction:column;align-items:stretch}.admin-password-field input,.admin-actions input[type=text],.admin-actions input[type=password]{min-width:0;width:100%}.run-shell{grid-template-columns:1fr}.run-head{flex-direction:column;align-items:flex-start}.run-form{grid-template-columns:1fr}.admin-login-screen{padding:18px}.admin-login-card{padding:26px 22px}}
|
||||
@media(max-width:640px){header{flex-direction:column;align-items:flex-start;gap:12px}nav{display:flex;flex-wrap:wrap;gap:10px}nav a{margin-left:0}.home-copy h2{max-width:none}.hero-actions{flex-direction:column;align-items:stretch}.hero-actions .btn,.roulette-btn{width:100%;text-align:center}.list-hero-panel,.list-controls-panel,.list-table-panel,.roulette-panel,.roulette-rules-card,.run-panel,.run-side-panel,.submission-panel{padding:22px 18px}.table-hint{text-align:left}.run-admin-actions{justify-content:flex-start}}
|
||||
@media(max-width:640px){header{flex-direction:column;align-items:flex-start;gap:12px}nav{display:flex;flex-wrap:wrap;gap:10px}nav a{margin-left:0}header nav a.btn{margin-left:0}.home-copy h2{max-width:none}.hero-actions{flex-direction:column;align-items:stretch}.hero-actions .btn,.roulette-btn{width:100%;text-align:center}.list-hero-panel,.list-controls-panel,.list-table-panel,.roulette-panel,.roulette-rules-card,.run-panel,.run-side-panel,.submission-panel{padding:22px 18px}.table-hint{text-align:left}.run-admin-actions{justify-content:flex-start}}
|
||||
|
||||
Reference in New Issue
Block a user