rip out entire auth/login flow
- remove requireAuth from all route files - delete auth.js, tokens.js, users.js routes - delete auth middleware - remove session middleware and all auth deps from index.js - delete login.html and auth-guard.js from web-ui
This commit is contained in:
parent
9726dbb2df
commit
4172b0d70a
24 changed files with 1 additions and 1094 deletions
|
|
@ -1,8 +1,6 @@
|
|||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import session from 'express-session';
|
||||
import ConnectPgSimple from 'connect-pg-simple';
|
||||
import os from 'node:os';
|
||||
import { exec } from 'node:child_process';
|
||||
import pool from './db/pool.js';
|
||||
|
|
@ -10,7 +8,6 @@ import { errorHandler } from './middleware/errors.js';
|
|||
import { loadS3ConfigFromDb } from './s3/client.js';
|
||||
|
||||
// Routes
|
||||
import authRouter from './routes/auth.js';
|
||||
import assetsRouter from './routes/assets.js';
|
||||
import projectsRouter from './routes/projects.js';
|
||||
import binsRouter from './routes/bins.js';
|
||||
|
|
@ -20,9 +17,7 @@ import uploadRouter from './routes/upload.js';
|
|||
import recordersRouter from './routes/recorders.js';
|
||||
import settingsRouter from './routes/settings.js';
|
||||
import amppRouter from './routes/ampp.js';
|
||||
import usersRouter from './routes/users.js';
|
||||
import groupsRouter from './routes/groups.js';
|
||||
import tokensRouter from './routes/tokens.js';
|
||||
import sequencesRouter from './routes/sequences.js';
|
||||
import systemRouter from './routes/system.js';
|
||||
import clusterRouter from './routes/cluster.js';
|
||||
|
|
@ -39,64 +34,13 @@ const app = express();
|
|||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// ── Middleware ────────────────────────────────────────────────────────────────
|
||||
// Trust the first proxy (nginx in front of us) so req.ip, req.secure, and
|
||||
// req.protocol reflect the real client request — required for both the
|
||||
// login rate-limiter's IP keying and `cookie.secure` cookie issuance.
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
app.use(cors({ origin: true, credentials: true }));
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
|
||||
const PgSession = ConnectPgSimple(session);
|
||||
|
||||
// Session security knobs.
|
||||
//
|
||||
// - `secure` is set from SESSION_COOKIE_SECURE (default: true when AUTH_ENABLED).
|
||||
// `trust proxy` above tells express-session that x-forwarded-proto can be
|
||||
// trusted, so it issues Secure cookies on HTTPS requests forwarded by
|
||||
// nginx/Cloudflare even though the proxy → mam-api hop is plain HTTP.
|
||||
// Set SESSION_COOKIE_SECURE=false explicitly for local-only HTTP testing.
|
||||
// - `sameSite: 'lax'` ships the cookie on top-level navigations (including
|
||||
// the post-login redirect from /login.html) but blocks cross-site POSTs.
|
||||
// - Renamed from default `connect.sid` to `df.sid` so it's obvious in DevTools.
|
||||
// - `rolling: true` refreshes maxAge on every request so an active user
|
||||
// doesn't get bounced to login after the 7-day TTL.
|
||||
const authEnabled = process.env.AUTH_ENABLED === 'true';
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET
|
||||
|| (authEnabled
|
||||
? (() => { throw new Error('SESSION_SECRET is required when AUTH_ENABLED=true'); })()
|
||||
: 'dev-only-not-for-production');
|
||||
|
||||
const SESSION_COOKIE_SECURE = process.env.SESSION_COOKIE_SECURE
|
||||
? process.env.SESSION_COOKIE_SECURE === 'true'
|
||||
: authEnabled; // default: secure cookies whenever auth is on
|
||||
|
||||
app.use(
|
||||
session({
|
||||
name: 'df.sid',
|
||||
store: new PgSession({
|
||||
pool,
|
||||
tableName: 'sessions',
|
||||
pruneSessionInterval: 3600,
|
||||
}),
|
||||
secret: SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
rolling: true,
|
||||
cookie: {
|
||||
secure: SESSION_COOKIE_SECURE,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// ── Health (no auth) ──────────────────────────────────────────────────────────
|
||||
// ── Health ────────────────────────────────────────────────────────────────────
|
||||
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
||||
|
||||
// ── API Routes ────────────────────────────────────────────────────────────────
|
||||
app.use('/api/v1/auth', authRouter);
|
||||
app.use('/api/v1/assets', assetsRouter);
|
||||
app.use('/api/v1/projects', projectsRouter);
|
||||
app.use('/api/v1/bins', binsRouter);
|
||||
|
|
@ -106,9 +50,7 @@ app.use('/api/v1/upload', uploadRouter);
|
|||
app.use('/api/v1/recorders', recordersRouter);
|
||||
app.use('/api/v1/settings', settingsRouter);
|
||||
app.use('/api/v1/ampp', amppRouter);
|
||||
app.use('/api/v1/users', usersRouter);
|
||||
app.use('/api/v1/groups', groupsRouter);
|
||||
app.use('/api/v1/tokens', tokensRouter);
|
||||
app.use('/api/v1/sequences', sequencesRouter);
|
||||
app.use('/api/v1/system', systemRouter);
|
||||
app.use('/api/v1/cluster', clusterRouter);
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
/**
|
||||
* Authentication middleware.
|
||||
*
|
||||
* When AUTH_ENABLED=true in the environment, every protected route requires
|
||||
* either:
|
||||
* - An active session (set by POST /api/v1/auth/login), or
|
||||
* - A valid Bearer token in Authorization header (set by POST /api/v1/tokens)
|
||||
*
|
||||
* When AUTH_ENABLED is unset or any other value, all middleware is a no-op so
|
||||
* the stack can be run without user accounts during development.
|
||||
*/
|
||||
import crypto from 'crypto';
|
||||
import pool from '../db/pool.js';
|
||||
|
||||
export const requireAuth = async (req, res, next) => {
|
||||
if (process.env.AUTH_ENABLED !== 'true') return next();
|
||||
|
||||
// ── Session-based auth ────────────────────────────────────────
|
||||
if (req.session?.userId) {
|
||||
req.user = {
|
||||
id: req.session.userId,
|
||||
username: req.session.username,
|
||||
role: req.session.role,
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
// ── Bearer token auth ─────────────────────────────────────────
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const raw = authHeader.slice(7).trim();
|
||||
const hash = crypto.createHash('sha256').update(raw).digest('hex');
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT t.user_id AS id, u.username, u.role, t.bound_hostname
|
||||
FROM api_tokens t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
WHERE t.token_hash = $1
|
||||
AND (t.expires_at IS NULL OR t.expires_at > NOW())`,
|
||||
[hash]
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
req.user = rows[0];
|
||||
req.tokenBoundHostname = rows[0].bound_hostname || null;
|
||||
// Fire-and-forget last_used_at update
|
||||
pool.query(
|
||||
'UPDATE api_tokens SET last_used_at = NOW() WHERE token_hash = $1',
|
||||
[hash]
|
||||
).catch(() => {});
|
||||
return next();
|
||||
}
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
};
|
||||
|
||||
export const requireAdmin = (req, res, next) => {
|
||||
if (process.env.AUTH_ENABLED !== 'true') return next();
|
||||
if (req.user?.role === 'admin') return next();
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
};
|
||||
|
|
@ -6,12 +6,9 @@ import path from 'node:path';
|
|||
import pool from '../db/pool.js';
|
||||
import { getSignedUrlForObject, deleteObject, s3Client, getS3Bucket } from '../s3/client.js';
|
||||
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { validateUuid } from '../middleware/errors.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||
|
||||
// BullMQ queue connection (mirrors worker/src/index.js)
|
||||
|
|
|
|||
|
|
@ -1,265 +0,0 @@
|
|||
/**
|
||||
* Authentication routes
|
||||
*
|
||||
* POST /api/v1/auth/login — exchange username+password for a session cookie
|
||||
* POST /api/v1/auth/logout — destroy the current session
|
||||
* GET /api/v1/auth/me — return the currently authenticated user
|
||||
* POST /api/v1/auth/setup — one-time admin bootstrap (disabled after first user exists)
|
||||
*/
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import pool from '../db/pool.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BUG FIX #6: In-memory login rate limiter.
|
||||
//
|
||||
// Brute-force protection for POST /login. Tracks failed attempts per
|
||||
// (IP, username) pair; after MAX_ATTEMPTS failures within WINDOW_MS the
|
||||
// endpoint returns 429 for LOCKOUT_MS regardless of the password supplied.
|
||||
//
|
||||
// This is intentionally simple — no Redis dependency, no persistent state
|
||||
// across restarts. For a production deployment behind a load balancer, use
|
||||
// express-rate-limit with a Redis store or a dedicated WAF rule instead.
|
||||
// ---------------------------------------------------------------------------
|
||||
const MAX_ATTEMPTS = parseInt(process.env.LOGIN_MAX_ATTEMPTS || '10', 10);
|
||||
const WINDOW_MS = parseInt(process.env.LOGIN_WINDOW_MS || String(15 * 60 * 1000), 10); // 15 min
|
||||
const LOCKOUT_MS = parseInt(process.env.LOGIN_LOCKOUT_MS || String(15 * 60 * 1000), 10); // 15 min
|
||||
|
||||
// Map key → { attempts: number, lockedUntil: number | null, firstAttempt: number }
|
||||
const loginAttempts = new Map();
|
||||
|
||||
// Housekeeping: prune expired entries every 10 min so the Map doesn't grow
|
||||
// unboundedly on high-traffic or attack traffic.
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of loginAttempts.entries()) {
|
||||
const expired = entry.lockedUntil
|
||||
? now > entry.lockedUntil
|
||||
: now - entry.firstAttempt > WINDOW_MS;
|
||||
if (expired) loginAttempts.delete(key);
|
||||
}
|
||||
}, 10 * 60 * 1000).unref();
|
||||
|
||||
function getAttemptKey(req, username) {
|
||||
// Use the real client IP (trust proxy headers set by nginx/load-balancer)
|
||||
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
return `${ip}:${(username || '').trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function checkRateLimit(req, username) {
|
||||
const key = getAttemptKey(req, username);
|
||||
const now = Date.now();
|
||||
const entry = loginAttempts.get(key);
|
||||
|
||||
if (entry) {
|
||||
// Still locked out?
|
||||
if (entry.lockedUntil && now < entry.lockedUntil) {
|
||||
const retryAfterSec = Math.ceil((entry.lockedUntil - now) / 1000);
|
||||
return { limited: true, retryAfterSec };
|
||||
}
|
||||
// Window expired — reset
|
||||
if (now - entry.firstAttempt > WINDOW_MS) {
|
||||
loginAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
return { limited: false };
|
||||
}
|
||||
|
||||
function recordFailedAttempt(req, username) {
|
||||
const key = getAttemptKey(req, username);
|
||||
const now = Date.now();
|
||||
const entry = loginAttempts.get(key) || { attempts: 0, lockedUntil: null, firstAttempt: now };
|
||||
|
||||
// Don't update firstAttempt if there's an existing entry within the window
|
||||
entry.attempts += 1;
|
||||
if (entry.attempts >= MAX_ATTEMPTS) {
|
||||
entry.lockedUntil = now + LOCKOUT_MS;
|
||||
}
|
||||
loginAttempts.set(key, entry);
|
||||
}
|
||||
|
||||
function clearAttempts(req, username) {
|
||||
loginAttempts.delete(getAttemptKey(req, username));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /login
|
||||
// ---------------------------------------------------------------------------
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
// BUG FIX #6: Check rate limit before hitting the DB or bcrypt.
|
||||
const rateCheck = checkRateLimit(req, username);
|
||||
if (rateCheck.limited) {
|
||||
res.set('Retry-After', String(rateCheck.retryAfterSec));
|
||||
return res.status(429).json({
|
||||
error: `Too many failed login attempts. Try again in ${rateCheck.retryAfterSec} seconds.`,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM users WHERE username = $1',
|
||||
[username.trim().toLowerCase()]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
// Timing-safe: still run compare on a dummy hash so response time is constant
|
||||
await bcrypt.compare(password, '$2b$12$invalidhashpadding000000000000000000000000000000000000');
|
||||
recordFailedAttempt(req, username);
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!valid) {
|
||||
recordFailedAttempt(req, username);
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Successful login — clear any accumulated failed attempts
|
||||
clearAttempts(req, username);
|
||||
|
||||
// Regenerate session ID to prevent fixation attacks, then let
|
||||
// express-session write Set-Cookie on its own res.end() hook.
|
||||
// Do NOT call session.save() manually — that writes the store but
|
||||
// bypasses the middleware's header-writing path, which is why the
|
||||
// Set-Cookie header was missing even though the session row existed.
|
||||
req.session.regenerate((err) => {
|
||||
if (err) {
|
||||
console.error('[auth] session.regenerate failed:', err);
|
||||
return next(err);
|
||||
}
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
req.session.role = user.role;
|
||||
console.log(`[auth] login ok user=${user.username} sid=${req.sessionID?.slice(0,8) || '?'} secure=${req.secure} proto=${req.protocol}`);
|
||||
res.json({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /logout
|
||||
// ---------------------------------------------------------------------------
|
||||
router.post('/logout', (req, res, next) => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) return next(err);
|
||||
res.clearCookie('connect.sid');
|
||||
res.json({ message: 'Logged out' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /me
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/me', async (req, res) => {
|
||||
// When auth is disabled return a synthetic user so the frontend auth-guard
|
||||
// never receives a 401. Prefer LOCAL_OPERATOR (explicit) or the OS user
|
||||
// running the server over a generic "Admin" — that label is misleading
|
||||
// because it implies an actual admin account is signed in.
|
||||
if (process.env.AUTH_ENABLED !== 'true') {
|
||||
const osUser = process.env.LOCAL_OPERATOR
|
||||
|| process.env.USER
|
||||
|| process.env.USERNAME
|
||||
|| 'operator';
|
||||
return res.json({
|
||||
id: null,
|
||||
username: osUser.toLowerCase().replace(/[^a-z0-9._-]/g, ''),
|
||||
display_name: osUser,
|
||||
role: 'admin',
|
||||
synthetic: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT id, username, display_name, role FROM users WHERE id = $1',
|
||||
[req.session.userId]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
req.session.destroy(() => {});
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
}
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) {
|
||||
// Fallback to session data if DB unreachable
|
||||
res.json({
|
||||
id: req.session.userId,
|
||||
username: req.session.username,
|
||||
role: req.session.role,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /setup-status — does ANY user exist? login.html flips into setup mode
|
||||
// automatically when this returns { needs_setup: true }, instead of forcing
|
||||
// the operator to click "Create admin account".
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/setup-status', async (req, res, next) => {
|
||||
try {
|
||||
const count = await pool.query('SELECT COUNT(*) FROM users');
|
||||
const n = parseInt(count.rows[0].count, 10);
|
||||
res.json({
|
||||
needs_setup: n === 0,
|
||||
user_count: n,
|
||||
auth_enabled: process.env.AUTH_ENABLED === 'true',
|
||||
});
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /setup — one-time first-admin bootstrap
|
||||
// ---------------------------------------------------------------------------
|
||||
router.post('/setup', async (req, res, next) => {
|
||||
try {
|
||||
const { username, password, display_name } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
}
|
||||
|
||||
// Block if any user already exists
|
||||
const count = await pool.query('SELECT COUNT(*) FROM users');
|
||||
if (parseInt(count.rows[0].count, 10) > 0) {
|
||||
return res.status(403).json({
|
||||
error: 'Setup is already complete. Use an existing admin account to add more users.',
|
||||
});
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
const result = await pool.query(
|
||||
`INSERT INTO users (username, password_hash, display_name, role)
|
||||
VALUES ($1, $2, $3, 'admin')
|
||||
RETURNING id, username, display_name, role`,
|
||||
[username.trim().toLowerCase(), hash, display_name || username]
|
||||
);
|
||||
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,12 +1,9 @@
|
|||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { validateUuid } from '../middleware/errors.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||
|
||||
// GET / - List bins. Filter by project_id when supplied; otherwise return
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import express from 'express';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
const CAPTURE_URL = process.env.CAPTURE_URL || 'http://capture:3001';
|
||||
|
||||
async function proxyRequest(method, path, body = null) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import express from 'express';
|
||||
import http from 'http';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// If the agent reported Docker's default bridge IP (172.17.x) but the request
|
||||
// itself came from a real LAN address, prefer the request source IP instead.
|
||||
|
|
|
|||
|
|
@ -5,10 +5,8 @@
|
|||
|
||||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
router.use(requireAuth);
|
||||
|
||||
function rowToJson(r) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -11,10 +11,8 @@
|
|||
*/
|
||||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth, requireAdmin } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth, requireAdmin);
|
||||
|
||||
// ── List ──────────────────────────────────────────────────────
|
||||
router.get('/', async (_req, res, next) => {
|
||||
|
|
|
|||
|
|
@ -10,10 +10,8 @@ import express from 'express';
|
|||
import { Queue } from 'bullmq';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const parseRedisUrl = (url) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { validateUuid } from '../middleware/errors.js';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||
|
||||
// ── Redis connection ──────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -5,10 +5,8 @@
|
|||
|
||||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const DEFAULT_HOURS = 24;
|
||||
const DEFAULT_POINTS = 13;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { validateUuid } from '../middleware/errors.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||
|
||||
// Helper function to slugify
|
||||
|
|
|
|||
|
|
@ -4,13 +4,10 @@ import net from 'net';
|
|||
import dgram from 'dgram';
|
||||
import pool from '../db/pool.js';
|
||||
import { getS3Bucket } from '../s3/client.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { validateUuid } from '../middleware/errors.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||
|
||||
// Base port for on-demand SDI sidecar containers on remote worker nodes.
|
||||
|
|
|
|||
|
|
@ -5,11 +5,9 @@
|
|||
|
||||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { validateUuid } from '../middleware/errors.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||
|
||||
const ALLOWED_RECURRENCE = new Set(['none', 'daily', 'weekly']);
|
||||
|
|
|
|||
|
|
@ -14,10 +14,8 @@ import multer from 'multer';
|
|||
import { promises as fs, createWriteStream } from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const SDK_ROOT = process.env.SDK_ROOT || '/sdk';
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { getSignedUrlForObject } from '../s3/client.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { validateUuid } from '../middleware/errors.js';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
|
|
@ -20,7 +19,6 @@ const conformQueue = new Queue('conform', {
|
|||
});
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||
|
||||
// ── Row mapper ────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { getAmppConfig } from '../ampp/client.js';
|
||||
import { rebuildS3Client, buildTestClient, testS3Connection, getS3Bucket } from '../s3/client.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// ── S3 / Object Storage ───────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,10 @@ import { promisify } from 'node:util';
|
|||
import { exec as execCb } from 'node:child_process';
|
||||
import { HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { s3Client, getS3Bucket } from '../s3/client.js';
|
||||
|
||||
const exec = promisify(execCb);
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// Defaults mirrored from settings.js so the overview never returns nulls.
|
||||
const GROWING_DEFAULTS = {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import express from 'express';
|
||||
import http from 'node:http';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const DOCKER_SOCKET = '/var/run/docker.sock';
|
||||
const COMPOSE_PROJECT = (process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon').split('_')[0];
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
/**
|
||||
* Personal API token routes (requires authentication)
|
||||
*
|
||||
* GET /api/v1/tokens — list current user's tokens (no raw values)
|
||||
* POST /api/v1/tokens — create token, returns raw value ONCE
|
||||
* DELETE /api/v1/tokens/:id — revoke token
|
||||
*/
|
||||
import express from 'express';
|
||||
import crypto from 'crypto';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// Helper: get current user ID from session or req.user
|
||||
const userId = req => req.user?.id || req.session?.userId;
|
||||
|
||||
// ── List ──────────────────────────────────────────────────────
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, name, token_prefix, last_used_at, expires_at, created_at, bound_hostname
|
||||
FROM api_tokens
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
[userId(req)]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Create ────────────────────────────────────────────────────
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { name, expires_in_days, bound_hostname } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'name required' });
|
||||
|
||||
// Generate: wd_ + 40 random hex chars = 43 chars total
|
||||
const raw = 'wd_' + crypto.randomBytes(20).toString('hex');
|
||||
const hash = crypto.createHash('sha256').update(raw).digest('hex');
|
||||
const prefix = raw.slice(0, 10); // "wd_" + first 7 hex chars
|
||||
|
||||
const expiresAt = expires_in_days
|
||||
? new Date(Date.now() + parseInt(expires_in_days, 10) * 86400000)
|
||||
: null;
|
||||
|
||||
// Issue #106 — `bound_hostname` ties a token to a single worker hostname.
|
||||
// Heartbeats are rejected if the body hostname doesn't match the binding,
|
||||
// preventing a stolen worker token from hijacking another node's api_url.
|
||||
const bound = bound_hostname && typeof bound_hostname === 'string'
|
||||
? bound_hostname.trim() || null
|
||||
: null;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, expires_at, bound_hostname)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, name, token_prefix, last_used_at, expires_at, created_at, bound_hostname`,
|
||||
[userId(req), name.trim(), hash, prefix, expiresAt, bound]
|
||||
);
|
||||
|
||||
// Return raw token ONCE — it is never stored in plaintext
|
||||
res.status(201).json({ ...rows[0], token: raw });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Revoke ────────────────────────────────────────────────────
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM api_tokens WHERE id = $1 AND user_id = $2`,
|
||||
[req.params.id, userId(req)]
|
||||
);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Token not found' });
|
||||
res.json({ message: 'Token revoked' });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
/**
|
||||
* User management routes (admin-only when AUTH_ENABLED=true)
|
||||
*
|
||||
* GET /api/v1/users — list all users
|
||||
* POST /api/v1/users — create user
|
||||
* GET /api/v1/users/:id — get user
|
||||
* PATCH /api/v1/users/:id — update user (display_name, role, password)
|
||||
* DELETE /api/v1/users/:id — delete user
|
||||
*/
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth, requireAdmin } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth, requireAdmin);
|
||||
|
||||
const VALID_ROLES = ['admin', 'editor', 'viewer'];
|
||||
|
||||
// ── List ──────────────────────────────────────────────────────
|
||||
router.get('/', async (_req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT u.id, u.username, u.display_name, u.role, u.created_at,
|
||||
COUNT(ug.group_id)::int AS group_count
|
||||
FROM users u
|
||||
LEFT JOIN user_groups ug ON ug.user_id = u.id
|
||||
GROUP BY u.id
|
||||
ORDER BY u.created_at`
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Create ────────────────────────────────────────────────────
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { username, password, display_name, role = 'editor' } = req.body;
|
||||
if (!username || !password)
|
||||
return res.status(400).json({ error: 'username and password required' });
|
||||
if (password.length < 8)
|
||||
return res.status(400).json({ error: 'Password must be ≥ 8 characters' });
|
||||
if (!VALID_ROLES.includes(role))
|
||||
return res.status(400).json({ error: `role must be one of: ${VALID_ROLES.join(', ')}` });
|
||||
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, password_hash, display_name, role)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, username, display_name, role, created_at`,
|
||||
[username.trim().toLowerCase(), hash, display_name || username, role]
|
||||
);
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (err) {
|
||||
if (err.code === '23505') return res.status(409).json({ error: 'Username already exists' });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Get ───────────────────────────────────────────────────────
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, display_name, role, created_at FROM users WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'User not found' });
|
||||
res.json(rows[0]);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Update ────────────────────────────────────────────────────
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { display_name, role, password } = req.body;
|
||||
const sets = []; const vals = [];
|
||||
let passwordChanged = false;
|
||||
|
||||
if (display_name !== undefined) {
|
||||
sets.push(`display_name = $${sets.length + 1}`);
|
||||
vals.push(display_name);
|
||||
}
|
||||
if (role !== undefined) {
|
||||
if (!VALID_ROLES.includes(role))
|
||||
return res.status(400).json({ error: `role must be one of: ${VALID_ROLES.join(', ')}` });
|
||||
sets.push(`role = $${sets.length + 1}`);
|
||||
vals.push(role);
|
||||
}
|
||||
if (password) {
|
||||
if (password.length < 8)
|
||||
return res.status(400).json({ error: 'Password must be ≥ 8 characters' });
|
||||
const hashed = await bcrypt.hash(password, 12);
|
||||
sets.push(`password_hash = $${sets.length + 1}`);
|
||||
vals.push(hashed);
|
||||
passwordChanged = true;
|
||||
}
|
||||
if (!sets.length) return res.status(400).json({ error: 'Nothing to update' });
|
||||
|
||||
vals.push(req.params.id);
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE users SET ${sets.join(', ')} WHERE id = $${vals.length}
|
||||
RETURNING id, username, display_name, role, created_at`,
|
||||
vals
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
// BUG FIX #5: Invalidate all active sessions for this user when their
|
||||
// password is changed. Without this, an attacker who has already stolen a
|
||||
// session cookie retains access even after the password is rotated, and a
|
||||
// user who changes their own password doesn't log out other devices.
|
||||
//
|
||||
// Implementation note: express-session stores sessions keyed by session ID,
|
||||
// not by userId. The standard way to invalidate by userId is to query the
|
||||
// session store. We support two common stores:
|
||||
//
|
||||
// 1. connect-pg-simple (Postgres): DELETE FROM sessions WHERE …
|
||||
// 2. connect-redis (Redis): requires iterating keys (expensive).
|
||||
//
|
||||
// We use a best-effort approach: if the session store exposes a `db`
|
||||
// (pg-simple) we DELETE directly. Otherwise we log a warning — operators
|
||||
// should configure a session store that supports this.
|
||||
if (passwordChanged && req.sessionStore) {
|
||||
try {
|
||||
const store = req.sessionStore;
|
||||
if (typeof store.query === 'function') {
|
||||
// connect-pg-simple exposes the pool as store.pool / store.client
|
||||
// The session data is a JSON blob; we match on the userId field.
|
||||
const pgPool = store.pool || store.client;
|
||||
if (pgPool) {
|
||||
await pgPool.query(
|
||||
`DELETE FROM sessions WHERE sess->>'userId' = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
console.log(`[users] Invalidated sessions for user ${req.params.id} after password change`);
|
||||
}
|
||||
} else if (typeof store.client === 'object' && typeof store.client.keys === 'function') {
|
||||
// connect-redis: scan for session keys containing this userId.
|
||||
// This is O(n) over all sessions — acceptable for small deployments.
|
||||
const prefix = store.prefix || 'sess:';
|
||||
const keys = await store.client.keys(`${prefix}*`);
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const raw = await store.client.get(key);
|
||||
if (!raw) continue;
|
||||
const data = JSON.parse(raw);
|
||||
if (String(data.userId) === String(req.params.id)) {
|
||||
await store.client.del(key);
|
||||
}
|
||||
} catch { /* skip malformed sessions */ }
|
||||
}
|
||||
console.log(`[users] Invalidated Redis sessions for user ${req.params.id} after password change`);
|
||||
} else {
|
||||
console.warn('[users] Session store does not support programmatic invalidation — existing sessions for this user remain valid after password change');
|
||||
}
|
||||
} catch (sessionErr) {
|
||||
// Non-fatal: the password is already changed; just log the failure.
|
||||
console.error('[users] Failed to invalidate sessions after password change:', sessionErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(rows[0]);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { rowCount } = await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
|
||||
if (!rowCount) return res.status(404).json({ error: 'User not found' });
|
||||
res.json({ message: 'User deleted' });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
/**
|
||||
* auth-guard.js
|
||||
* Included on every protected page.
|
||||
*
|
||||
* - If /api/v1/auth/me returns 401 → redirect to login.html immediately.
|
||||
* (When AUTH_ENABLED=false the endpoint returns a synthetic guest user,
|
||||
* so the redirect only fires in production auth-enabled mode.)
|
||||
* - On success, populate the sidebar user widget and wire up the logout button.
|
||||
* - Side effect: marks any sidebar link pointing at editor.html with an
|
||||
* "IN DEV" badge, so we don't have to touch every HTML file individually.
|
||||
*/
|
||||
|
||||
// ── Cross-page IN-DEV markers ─────────────────────────────────────────────
|
||||
// Add (page-name → label) here to flag a sidebar item without editing all
|
||||
// 13 HTML files. The CSS + DOM patch runs once on every page.
|
||||
const IN_DEV_PAGES = {
|
||||
'editor.html': 'IN DEV',
|
||||
};
|
||||
|
||||
(function tagInDevNavItems() {
|
||||
// 1. Inject the badge styles once. Kept inline so we don't add another
|
||||
// HTTP request per page — and so the rule can't get out of sync with
|
||||
// the auth-guard that toggles it.
|
||||
if (!document.getElementById('in-dev-style')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'in-dev-style';
|
||||
style.textContent = `
|
||||
.nav-item.is-in-dev { position: relative; }
|
||||
.nav-item.is-in-dev .nav-dev-badge {
|
||||
margin-left: auto;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: oklch(28% 0.14 80 / 0.45);
|
||||
color: oklch(85% 0.16 85);
|
||||
border: 1px solid oklch(50% 0.16 80 / 0.55);
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-item.is-in-dev:hover .nav-dev-badge {
|
||||
background: oklch(32% 0.16 80 / 0.55);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// 2. Walk every sidebar nav-item link and tag the ones whose href matches
|
||||
// a known in-dev page. Href is matched case-insensitively against the
|
||||
// final path segment so '/editor.html', 'editor.html', and absolute
|
||||
// URLs all hit.
|
||||
const links = document.querySelectorAll('.nav-item');
|
||||
links.forEach((a) => {
|
||||
const href = (a.getAttribute('href') || '').toLowerCase();
|
||||
const last = href.split('/').pop().split('?')[0];
|
||||
const label = IN_DEV_PAGES[last];
|
||||
if (!label) return;
|
||||
if (a.querySelector('.nav-dev-badge')) return; // idempotent
|
||||
a.classList.add('is-in-dev');
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'nav-dev-badge';
|
||||
badge.textContent = label;
|
||||
a.appendChild(badge);
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Auth + user widget ────────────────────────────────────────────────────
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/v1/auth/me', { credentials: 'include' });
|
||||
if (r.status === 401) {
|
||||
location.replace('login.html');
|
||||
return;
|
||||
}
|
||||
if (r.ok) {
|
||||
const u = await r.json();
|
||||
const name = u.display_name || u.username || 'User';
|
||||
const userNameEl = document.getElementById('userName');
|
||||
const userAvatarEl = document.getElementById('userAvatar');
|
||||
const userRoleEl = document.getElementById('userRole');
|
||||
if (userNameEl) userNameEl.textContent = name;
|
||||
if (userAvatarEl) userAvatarEl.textContent = name[0].toUpperCase();
|
||||
if (userRoleEl) userRoleEl.textContent = u.role || '';
|
||||
}
|
||||
} catch (_) {
|
||||
// Network error — don't redirect; the user may be on a dev build without auth.
|
||||
}
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.onclick = async () => {
|
||||
try { await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' }); } catch (_) {}
|
||||
location.href = 'login.html';
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
|
@ -1,317 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<title>Sign in - Dragonflight</title>
|
||||
<link rel="stylesheet" href="/dist/app.css">
|
||||
<style>
|
||||
/* Page-only layout. Everything visual is from /dist/app.css primitives. */
|
||||
body { display: grid; grid-template-columns: 1fr 460px; min-height: 100vh; margin: 0; }
|
||||
@media (max-width: 900px) {
|
||||
body { grid-template-columns: 1fr; grid-template-rows: 40vh 1fr; }
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(ellipse at 30% 40%, var(--bg-panel) 0%, var(--bg-deep) 70%);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 48px 56px;
|
||||
}
|
||||
.hero-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url(img/ampp-safe.png?v=hardhat3);
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.hero-grad-bot {
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
height: 40%;
|
||||
background: linear-gradient(to top, var(--bg-deep) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hero-stamp {
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
z-index: 2;
|
||||
background: var(--overlay);
|
||||
backdrop-filter: blur(6px);
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.hero-stamp-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px var(--accent);
|
||||
}
|
||||
.hero-stamp-text {
|
||||
font: 600 10px/1 var(--font);
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-bright);
|
||||
}
|
||||
.hero-caption {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 520px;
|
||||
}
|
||||
.hero-caption h2 {
|
||||
font: 600 28px/1.15 var(--font);
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.hero-caption p {
|
||||
font: 400 14px/1.55 var(--font);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
.hero-caption .accent { color: var(--accent-bright); }
|
||||
|
||||
/* Right column: brand + form panel */
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 48px 56px;
|
||||
background: var(--bg-panel);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.panel { padding: 32px 24px; border-left: none; border-top: 1px solid var(--border); }
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.brand-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-deep);
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font: 700 15px/1 var(--font);
|
||||
}
|
||||
.brand-icon img {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
.brand-name {
|
||||
font: 600 15px/1.2 var(--font);
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.brand-sub {
|
||||
font: 500 10px/1 var(--font);
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.panel h1 {
|
||||
font: 600 22px/1.2 var(--font);
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.panel .subtitle {
|
||||
font: 400 13px/1.5 var(--font);
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
|
||||
/* Flash messages — inline toast-shaped */
|
||||
.flash {
|
||||
display: none;
|
||||
position: relative;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 18px;
|
||||
font: 400 13px/1.4 var(--font);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.flash::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 3px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
.flash.error { display: block; border-color: var(--signal-bad); }
|
||||
.flash.error::before { background: var(--signal-bad); }
|
||||
.flash.success { display: block; border-color: var(--signal-good); }
|
||||
.flash.success::before { background: var(--signal-good); }
|
||||
.flash.info { display: block; border-color: var(--accent, #5b7cfa); }
|
||||
.flash.info::before { background: var(--accent, #5b7cfa); }
|
||||
|
||||
.setup-link {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
font: 400 12px/1.4 var(--font);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.setup-link a {
|
||||
color: var(--accent-bright);
|
||||
text-decoration: none;
|
||||
}
|
||||
.setup-link a:hover { color: var(--accent); text-decoration: underline; }
|
||||
|
||||
#setup-panel { display: none; }
|
||||
|
||||
/* Make the login button full-width (the primitive is inline-flex by default) */
|
||||
.wd-btn--full { width: 100%; margin-top: 10px; }
|
||||
|
||||
/* Stack form groups vertically with consistent gap */
|
||||
.login-form { display: flex; flex-direction: column; gap: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<section class="hero" aria-hidden="true">
|
||||
<div class="hero-img"></div>
|
||||
<div class="hero-grad-bot"></div>
|
||||
<div class="hero-stamp">
|
||||
<span class="hero-stamp-dot"></span>
|
||||
<span class="hero-stamp-text">Dragonflight</span>
|
||||
</div>
|
||||
<div class="hero-caption">
|
||||
<h2>Broadcast-safe media,<br><span class="accent">end to end.</span></h2>
|
||||
<p>Dragonflight is the Wild Dragon media-asset hub. Ingest live SRT & RTMP feeds, upload masters, and hand proxies to your editors in seconds.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="brand">
|
||||
<div class="brand-icon">
|
||||
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:20px;height:20px;">
|
||||
</div>
|
||||
<div>
|
||||
<div class="brand-name">Dragonflight</div>
|
||||
<div class="brand-sub">Media Asset Management</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="flash" class="flash"></div>
|
||||
|
||||
<div id="login-panel">
|
||||
<h1>Sign in</h1>
|
||||
<p class="subtitle">Enter your credentials to continue.</p>
|
||||
<form id="login-form" class="login-form" autocomplete="on">
|
||||
<div class="wd-form-group">
|
||||
<label class="wd-label" for="username">Username</label>
|
||||
<input class="wd-input" id="username" name="username" type="text" autocomplete="username" required placeholder="username">
|
||||
</div>
|
||||
<div class="wd-form-group">
|
||||
<label class="wd-label" for="password">Password</label>
|
||||
<input class="wd-input" id="password" name="password" type="password" autocomplete="current-password" required placeholder="••••••••">
|
||||
</div>
|
||||
<button type="submit" class="wd-btn wd-btn--primary wd-btn--md wd-btn--full" id="login-btn">Sign in</button>
|
||||
</form>
|
||||
<div class="setup-link">First time? <a href="#" id="show-setup">Create admin account</a></div>
|
||||
</div>
|
||||
|
||||
<div id="setup-panel">
|
||||
<h1>Create admin</h1>
|
||||
<p class="subtitle">No accounts exist yet, create the first admin.</p>
|
||||
<form id="setup-form" class="login-form" autocomplete="off">
|
||||
<div class="wd-form-group">
|
||||
<label class="wd-label" for="su-username">Username</label>
|
||||
<input class="wd-input" id="su-username" name="username" type="text" required placeholder="admin">
|
||||
</div>
|
||||
<div class="wd-form-group">
|
||||
<label class="wd-label" for="su-password">Password (min 8 chars)</label>
|
||||
<input class="wd-input" id="su-password" name="password" type="password" autocomplete="new-password" required minlength="8" placeholder="••••••••">
|
||||
</div>
|
||||
<div class="wd-form-group">
|
||||
<label class="wd-label" for="su-display">Display name (optional)</label>
|
||||
<input class="wd-input" id="su-display" name="display_name" type="text" placeholder="Admin">
|
||||
</div>
|
||||
<button type="submit" class="wd-btn wd-btn--primary wd-btn--md wd-btn--full" id="setup-btn">Create account</button>
|
||||
</form>
|
||||
<div class="setup-link"><a href="#" id="show-login">Back to login</a></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const API = '/api/v1/auth';
|
||||
const $ = id => document.getElementById(id);
|
||||
const flash = $('flash');
|
||||
function showFlash(m,t){ flash.textContent=m; flash.className='flash '+t; }
|
||||
function clearFlash(){ flash.className='flash'; flash.textContent=''; }
|
||||
function showSetup(){ $('login-panel').style.display='none'; $('setup-panel').style.display='block'; }
|
||||
function showLogin(){ $('setup-panel').style.display='none'; $('login-panel').style.display='block'; }
|
||||
|
||||
$('show-setup').onclick = e => { e.preventDefault(); clearFlash(); showSetup(); };
|
||||
$('show-login').onclick = e => { e.preventDefault(); clearFlash(); showLogin(); };
|
||||
|
||||
// Auth is parked for now. If the server reports auth is disabled, bounce
|
||||
// straight to the app — no one should ever land on this screen while
|
||||
// AUTH_ENABLED=false. If the server is unreachable, leave the panel
|
||||
// visible so the operator at least sees something.
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch(API + '/setup-status', { credentials: 'same-origin' });
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
if (!d.auth_enabled) {
|
||||
location.replace('/');
|
||||
return;
|
||||
}
|
||||
if (d.needs_setup) {
|
||||
showSetup();
|
||||
showFlash('No accounts yet — create the first admin to continue.', 'info');
|
||||
}
|
||||
}
|
||||
} catch (_) { /* offline → leave the login panel visible */ }
|
||||
})();
|
||||
|
||||
$('login-form').onsubmit = async (e) => {
|
||||
e.preventDefault(); clearFlash();
|
||||
const btn=$('login-btn'); btn.disabled=true; btn.textContent='Signing in...';
|
||||
try{
|
||||
const res = await fetch(API + '/login', {method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',
|
||||
body: JSON.stringify({username:$('username').value.trim(),password:$('password').value})});
|
||||
if(res.ok){ showFlash('Signed in, redirecting...','success'); setTimeout(()=>{location.href='/'},600); }
|
||||
else{ const d=await res.json().catch(()=>({})); showFlash(d.error||'Login failed','error'); }
|
||||
} catch(err){ showFlash('Network error: '+err.message,'error'); }
|
||||
finally{ btn.disabled=false; btn.textContent='Sign in'; }
|
||||
};
|
||||
|
||||
$('setup-form').onsubmit = async (e) => {
|
||||
e.preventDefault(); clearFlash();
|
||||
const btn=$('setup-btn'); btn.disabled=true; btn.textContent='Creating...';
|
||||
try{
|
||||
const res = await fetch(API + '/setup', {method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',
|
||||
body: JSON.stringify({username:$('su-username').value.trim(),password:$('su-password').value,display_name:$('su-display').value.trim()})});
|
||||
if(res.ok){
|
||||
showFlash('Admin account created, you can now log in','success');
|
||||
setTimeout(()=>{ $('setup-panel').style.display='none'; $('login-panel').style.display='block'; },1200);
|
||||
} else{
|
||||
const d=await res.json().catch(()=>({}));
|
||||
showFlash(d.error||'Setup failed','error');
|
||||
}
|
||||
} catch(err){ showFlash('Network error: '+err.message,'error'); }
|
||||
finally{ btn.disabled=false; btn.textContent='Create account'; }
|
||||
};
|
||||
</script>
|
||||
</body></html>
|
||||
Loading…
Reference in a new issue