Files
award/src/backend/index.js
Joerg 205b311244 fix: handle foreign key constraints when deleting QSOs
The qso_changes table has a foreign key reference to qsos.id, which
was preventing QSO deletion. Now deletes related qso_changes records
first before deleting QSOs.

Also added better error logging to the DELETE endpoint.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:26:43 +01:00

1557 lines
42 KiB
JavaScript

import { Elysia, t } from 'elysia';
import { cors } from '@elysiajs/cors';
import { jwt } from '@elysiajs/jwt';
import { resolve, normalize } from 'path';
import { existsSync } from 'fs';
import { JWT_SECRET, logger, LOG_LEVEL, logToFrontend } from './config.js';
import { getPerformanceSummary, resetPerformanceMetrics } from './services/performance.service.js';
import { getCacheStats } from './services/cache.service.js';
import {
registerUser,
authenticateUser,
getUserById,
updateLoTWCredentials,
updateDCLCredentials,
} from './services/auth.service.js';
import {
getSystemStats,
getUserStats,
getAdminActions,
impersonateUser,
verifyImpersonation,
stopImpersonation,
getImpersonationStatus,
changeUserRole,
deleteUser,
} from './services/admin.service.js';
import { getAllUsers } from './services/auth.service.js';
import {
getUserQSOs,
getQSOStats,
deleteQSOs,
getQSOById,
} from './services/lotw.service.js';
import {
enqueueJob,
getJobStatus,
getUserActiveJob,
getUserJobs,
cancelJob,
} from './services/job-queue.service.js';
import {
getAllAwards,
getAwardProgressDetails,
getAwardEntityBreakdown,
} from './services/awards.service.js';
/**
* Main backend application
* Serves API routes and static frontend files
*/
// SECURITY: Stricter email validation
// Elysia's built-in email format check is lenient; this provides better validation
// Allows: alphanumeric, dots, hyphens, underscores in local part
// Requires: valid domain with at least one dot
const EMAIL_REGEX = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
function isValidEmail(email) {
// Basic format check with regex
if (!EMAIL_REGEX.test(email)) {
return false;
}
// Additional checks
const [local, domain] = email.split('@');
// Local part shouldn't start or end with dot
if (local.startsWith('.') || local.endsWith('.')) {
return false;
}
// Local part shouldn't have consecutive dots
if (local.includes('..')) {
return false;
}
// Domain must have at least one dot and valid TLD
if (!domain || !domain.includes('.') || domain.split('.').pop().length < 2) {
return false;
}
// Length checks (RFC 5321 limits)
if (email.length > 254 || local.length > 64) {
return false;
}
return true;
}
// SECURITY: Validate job ID parameter
// Prevents injection and DoS via extremely large numbers
function isValidJobId(jobId) {
const id = parseInt(jobId, 10);
// Check: must be a number, positive, and within reasonable range (1 to 2^31-1)
return !isNaN(id) && id > 0 && id <= 2147483647 && Number.isSafeInteger(id);
}
// SECURITY: Validate QSO ID parameter
function isValidQsoId(qsoId) {
const id = parseInt(qsoId, 10);
return !isNaN(id) && id > 0 && id <= 2147483647 && Number.isSafeInteger(id);
}
// SECURITY: In-memory rate limiter for auth endpoints
// Prevents brute force attacks on login/register
const rateLimitStore = new Map(); // IP -> { count, resetTime }
function checkRateLimit(ip, limit = 5, windowMs = 60000) {
const now = Date.now();
const record = rateLimitStore.get(ip);
// Clean up expired records
if (record && now > record.resetTime) {
rateLimitStore.delete(ip);
return { allowed: true, remaining: limit - 1 };
}
// Check if limit exceeded
if (record) {
if (record.count >= limit) {
const retryAfter = Math.ceil((record.resetTime - now) / 1000);
return { allowed: false, retryAfter };
}
record.count++;
return { allowed: true, remaining: limit - record.count };
}
// Create new record
rateLimitStore.set(ip, {
count: 1,
resetTime: now + windowMs,
});
return { allowed: true, remaining: limit - 1 };
}
// SECURITY: Validate that a path doesn't escape the allowed directory
// Prevents path traversal attacks like ../../../etc/passwd
const BUILD_DIR = resolve('src/frontend/build');
function isPathSafe(requestedPath) {
try {
// Normalize the path to resolve any ../ sequences
const normalized = normalize(requestedPath);
const fullPath = resolve(BUILD_DIR, normalized);
// Check if the resolved path is within the build directory
return fullPath.startsWith(BUILD_DIR);
} catch {
return false;
}
}
// Get allowed origins from environment
// SECURITY: Never allow all origins, even in development
// Always require explicit ALLOWED_ORIGINS or VITE_APP_URL
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',')
: process.env.NODE_ENV === 'production'
? [process.env.VITE_APP_URL || 'https://awards.dj7nt.de']
: [process.env.VITE_APP_URL || 'http://localhost:5173']; // Default dev origin
const app = new Elysia()
// Enable CORS for frontend communication
.use(cors({
origin: ALLOWED_ORIGINS,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
}))
// JWT plugin
.use(jwt({
name: 'jwt',
secret: JWT_SECRET,
}))
// Authentication: derive user from JWT token
.derive(async ({ jwt, headers }) => {
const authHeader = headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return { user: null };
}
const token = authHeader.substring(7);
try {
const payload = await jwt.verify(token);
if (!payload) {
return { user: null };
}
// Check if this is an impersonation token
const isImpersonation = !!payload.impersonatedBy;
return {
user: {
id: payload.userId,
email: payload.email,
callsign: payload.callsign,
isAdmin: payload.isAdmin,
impersonatedBy: payload.impersonatedBy, // Admin ID if impersonating
},
isImpersonation,
};
} catch (error) {
return { user: null };
}
})
// Security headers middleware
.onAfterHandle(({ set, request }) => {
const url = new URL(request.url);
const isProduction = process.env.NODE_ENV === 'production';
// Prevent clickjacking
set.headers['X-Frame-Options'] = 'DENY';
// Prevent MIME type sniffing
set.headers['X-Content-Type-Options'] = 'nosniff';
// Enable XSS filter
set.headers['X-XSS-Protection'] = '1; mode=block';
// Referrer policy
set.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin';
// Content Security Policy (basic protection)
// In development, allow any origin; in production, restrict
const appUrl = process.env.VITE_APP_URL || 'https://awards.dj7nt.de';
const connectSrc = isProduction ? `'self' ${appUrl}` : "'self' *";
set.headers['Content-Security-Policy'] = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'`, // unsafe-inline needed for SvelteKit inline scripts
`style-src 'self' 'unsafe-inline'`, // unsafe-inline needed for SvelteKit inline styles
`img-src 'self' data: https:`,
`connect-src ${connectSrc}`,
"font-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; ');
// Strict Transport Security (HTTPS only) - only in production
if (isProduction) {
set.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains';
}
})
// Request logging middleware
.onRequest(({ request, params }) => {
const url = new URL(request.url);
const method = request.method;
const path = url.pathname;
const query = url.search;
// Skip logging for health checks in development to reduce noise
if (path === '/api/health' && process.env.NODE_ENV === 'development') {
return;
}
logger.info('Incoming request', {
method,
path,
query: query || undefined,
ip: request.headers.get('x-forwarded-for') || 'unknown',
userAgent: request.headers.get('user-agent')?.substring(0, 100),
});
})
.onAfterHandle(({ request, set }) => {
const url = new URL(request.url);
const path = url.pathname;
// Skip logging for health checks in development
if (path === '/api/health' && process.env.NODE_ENV === 'development') {
return;
}
// Log error responses
if (set.status >= 400) {
logger.warn('Request failed', {
path,
status: set.status,
});
}
})
/**
* POST /api/auth/register
* Register a new user
*/
.post(
'/api/auth/register',
async ({ body, jwt, set, request }) => {
// SECURITY: Rate limiting to prevent abuse
const ip = request.headers.get('x-forwarded-for') || 'unknown';
const rateLimit = checkRateLimit(ip, 5, 60000); // 5 requests per minute
if (!rateLimit.allowed) {
set.status = 429;
set.headers['Retry-After'] = rateLimit.retryAfter.toString();
return {
success: false,
error: 'Too many registration attempts. Please try again later.',
};
}
set.headers['X-RateLimit-Remaining'] = rateLimit.remaining.toString();
// SECURITY: Additional server-side email validation
if (!isValidEmail(body.email)) {
set.status = 400;
return {
success: false,
error: 'Invalid email address format',
};
}
// Create user
const user = await registerUser(body);
if (!user) {
set.status = 400;
return {
success: false,
error: 'Email already registered',
};
}
// Generate JWT token with expiration (24 hours)
const exp = Math.floor(Date.now() / 1000) + (24 * 60 * 60); // 24 hours from now
const token = await jwt.sign({
userId: user.id,
email: user.email,
callsign: user.callsign,
exp,
});
set.status = 201;
return {
success: true,
token,
user,
};
},
{
body: t.Object({
email: t.String({
format: 'email',
error: 'Invalid email address',
}),
password: t.String({
minLength: 8,
error: 'Password must be at least 8 characters',
}),
callsign: t.String({
minLength: 3,
maxLength: 10,
error: 'Callsign must be 3-10 characters',
}),
}),
}
)
/**
* POST /api/auth/login
* Authenticate user and return JWT token
*/
.post(
'/api/auth/login',
async ({ body, jwt, set, request }) => {
// SECURITY: Rate limiting to prevent brute force attacks
const ip = request.headers.get('x-forwarded-for') || 'unknown';
const rateLimit = checkRateLimit(ip, 10, 60000); // 10 requests per minute (more lenient than register)
if (!rateLimit.allowed) {
set.status = 429;
set.headers['Retry-After'] = rateLimit.retryAfter.toString();
return {
success: false,
error: 'Too many login attempts. Please try again later.',
};
}
set.headers['X-RateLimit-Remaining'] = rateLimit.remaining.toString();
// Authenticate user
const user = await authenticateUser(body.email, body.password);
if (!user) {
set.status = 401;
return {
success: false,
error: 'Invalid email or password',
};
}
// Generate JWT token with expiration (24 hours)
const exp = Math.floor(Date.now() / 1000) + (24 * 60 * 60); // 24 hours from now
const token = await jwt.sign({
userId: user.id,
email: user.email,
callsign: user.callsign,
isAdmin: user.isAdmin,
exp,
});
return {
success: true,
token,
user,
};
},
{
body: t.Object({
email: t.String({ format: 'email' }),
password: t.String(),
}),
}
)
/**
* GET /api/auth/me
* Get current user profile (requires authentication)
*/
.get('/api/auth/me', async ({ user, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
// Get full user data from database
const userData = await getUserById(user.id);
if (!userData) {
set.status = 404;
return { success: false, error: 'User not found' };
}
// Include impersonatedBy from JWT if present (not stored in database)
const responseUser = {
...userData,
impersonatedBy: user.impersonatedBy,
};
return {
success: true,
user: responseUser,
};
})
/**
* PUT /api/auth/lotw-credentials
* Update LoTW credentials (requires authentication)
*/
.put(
'/api/auth/lotw-credentials',
async ({ user, body, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
// Get current user data to preserve password if not provided
const userData = await getUserById(user.id);
if (!userData) {
set.status = 404;
return { success: false, error: 'User not found' };
}
// If password is empty, keep existing password
const lotwPassword = body.lotwPassword || userData.lotwPassword;
await updateLoTWCredentials(user.id, body.lotwUsername, lotwPassword);
return {
success: true,
message: 'LoTW credentials updated successfully',
};
} catch (error) {
set.status = 500;
return {
success: false,
error: 'Failed to update LoTW credentials',
};
}
},
{
body: t.Object({
lotwUsername: t.String(),
lotwPassword: t.Optional(t.String()),
}),
}
)
/**
* PUT /api/auth/dcl-credentials
* Update DCL credentials (requires authentication)
*/
.put(
'/api/auth/dcl-credentials',
async ({ user, body, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
await updateDCLCredentials(user.id, body.dclApiKey);
return {
success: true,
message: 'DCL credentials updated successfully',
};
} catch (error) {
set.status = 500;
return {
success: false,
error: 'Failed to update DCL credentials',
};
}
},
{
body: t.Object({
dclApiKey: t.String(),
}),
}
)
/**
* POST /api/lotw/sync
* Queue a LoTW sync job (requires authentication)
* Returns immediately with job ID
*/
.post('/api/lotw/sync', async ({ user, set }) => {
if (!user) {
logger.warn('/api/lotw/sync: Unauthorized access attempt');
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const result = await enqueueJob(user.id, 'lotw_sync');
if (!result.success && result.existingJob) {
return {
success: true,
jobId: result.existingJob,
message: 'A LoTW sync job is already running',
};
}
return result;
} catch (error) {
logger.error('Error in /api/lotw/sync', { error: error.message });
set.status = 500;
return {
success: false,
error: `Failed to queue LoTW sync job: ${error.message}`,
};
}
})
/**
* POST /api/dcl/sync
* Queue a DCL sync job (requires authentication)
* Returns immediately with job ID
*/
.post('/api/dcl/sync', async ({ user, set }) => {
if (!user) {
logger.warn('/api/dcl/sync: Unauthorized access attempt');
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const result = await enqueueJob(user.id, 'dcl_sync');
if (!result.success && result.existingJob) {
return {
success: true,
jobId: result.existingJob,
message: 'A DCL sync job is already running',
};
}
return result;
} catch (error) {
logger.error('Error in /api/dcl/sync', { error: error.message });
set.status = 500;
return {
success: false,
error: `Failed to queue DCL sync job: ${error.message}`,
};
}
})
/**
* GET /api/jobs/:jobId
* Get job status (requires authentication)
*/
.get('/api/jobs/:jobId', async ({ user, params, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
// SECURITY: Validate job ID to prevent injection and DoS
if (!isValidJobId(params.jobId)) {
set.status = 400;
return { success: false, error: 'Invalid job ID' };
}
const jobId = parseInt(params.jobId, 10);
const job = await getJobStatus(jobId);
if (!job) {
set.status = 404;
return { success: false, error: 'Job not found' };
}
// Verify user owns this job
if (job.userId !== user.id) {
set.status = 403;
return { success: false, error: 'Forbidden' };
}
return {
success: true,
job,
};
} catch (error) {
set.status = 500;
return {
success: false,
error: 'Failed to fetch job status',
};
}
})
/**
* GET /api/jobs/active
* Get user's active job (requires authentication)
*/
.get('/api/jobs/active', async ({ user, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const job = await getUserActiveJob(user.id);
if (!job) {
return {
success: true,
job: null,
};
}
return {
success: true,
job: {
id: job.id,
type: job.type,
status: job.status,
createdAt: job.createdAt,
startedAt: job.startedAt,
},
};
} catch (error) {
set.status = 500;
return {
success: false,
error: 'Failed to fetch active job',
};
}
})
/**
* GET /api/jobs
* Get user's recent jobs (requires authentication)
*/
.get('/api/jobs', async ({ user, query, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const limit = query.limit ? parseInt(query.limit) : 10;
const jobs = await getUserJobs(user.id, limit);
return {
success: true,
jobs,
};
} catch (error) {
set.status = 500;
return {
success: false,
error: 'Failed to fetch jobs',
};
}
})
/**
* DELETE /api/jobs/:jobId
* Cancel and rollback a sync job (requires authentication)
* Only allows cancelling failed, completed, or stale running jobs (>1 hour)
*/
.delete('/api/jobs/:jobId', async ({ user, params, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
// SECURITY: Validate job ID to prevent injection and DoS
if (!isValidJobId(params.jobId)) {
set.status = 400;
return { success: false, error: 'Invalid job ID' };
}
const jobId = parseInt(params.jobId, 10);
const result = await cancelJob(jobId, user.id);
if (!result.success) {
set.status = 400;
return result;
}
return result;
} catch (error) {
logger.error('Error cancelling job', { error: error.message, userId: user?.id, jobId: params.jobId });
set.status = 500;
return {
success: false,
error: 'Failed to cancel job',
};
}
})
/**
* GET /api/qsos
* Get user's QSOs (requires authentication)
* Supports pagination: ?page=1&limit=100
* Supports filters: band, mode, confirmed, confirmationType (all, lotw, dcl, both, none), search
*/
.get('/api/qsos', async ({ user, query, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const filters = {};
if (query.band) filters.band = query.band;
if (query.mode) filters.mode = query.mode;
if (query.confirmed) filters.confirmed = query.confirmed === 'true';
if (query.confirmationType && query.confirmationType !== 'all') {
filters.confirmationType = query.confirmationType;
}
if (query.search) filters.search = query.search;
// Pagination options
const options = {
page: query.page ? parseInt(query.page) : 1,
limit: query.limit ? parseInt(query.limit) : 100,
};
const result = await getUserQSOs(user.id, filters, options);
return {
success: true,
...result,
};
} catch (error) {
set.status = 500;
return {
success: false,
error: 'Failed to fetch QSOs',
};
}
})
/**
* GET /api/qsos/:id
* Get a single QSO by ID (requires authentication)
*/
.get('/api/qsos/:id', async ({ user, params, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
// SECURITY: Validate QSO ID to prevent injection and DoS
if (!isValidQsoId(params.id)) {
set.status = 400;
return { success: false, error: 'Invalid QSO ID' };
}
const qsoId = parseInt(params.id, 10);
const qso = await getQSOById(user.id, qsoId);
if (!qso) {
set.status = 404;
return { success: false, error: 'QSO not found' };
}
return {
success: true,
qso,
};
} catch (error) {
logger.error('Failed to fetch QSO by ID', { error: error.message, userId: user?.id, qsoId: params.id });
set.status = 500;
return {
success: false,
error: 'Failed to fetch QSO',
};
}
})
/**
* GET /api/qsos/stats
* Get QSO statistics (requires authentication)
*/
.get('/api/qsos/stats', async ({ user, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const stats = await getQSOStats(user.id);
return {
success: true,
stats,
};
} catch (error) {
set.status = 500;
return {
success: false,
error: 'Failed to fetch statistics',
};
}
})
/**
* DELETE /api/qsos/all
* Delete all QSOs for authenticated user
*/
.delete('/api/qsos/all', async ({ user, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const deleted = await deleteQSOs(user.id);
return {
success: true,
deleted,
message: `Deleted ${deleted} QSO(s)`,
};
} catch (error) {
logger.error('Failed to delete QSOs', { error: error.message, stack: error.stack });
set.status = 500;
return {
success: false,
error: 'Failed to delete QSOs',
};
}
})
/**
* GET /api/awards
* Get all available awards (requires authentication)
*/
.get('/api/awards', async ({ user, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const awards = await getAllAwards();
return {
success: true,
awards,
};
} catch (error) {
logger.error('Error fetching awards', { error: error.message });
set.status = 500;
return {
success: false,
error: 'Failed to fetch awards',
};
}
})
/**
* GET /api/awards/:awardId/progress
* Get award progress for user (requires authentication)
*/
.get('/api/awards/:awardId/progress', async ({ user, params, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
const { awardId } = params;
const progress = await getAwardProgressDetails(user.id, awardId);
if (!progress) {
set.status = 404;
return {
success: false,
error: 'Award not found',
};
}
return {
success: true,
...progress,
};
})
/**
* GET /api/awards/batch/progress
* Get progress for ALL awards in a single request (fixes N+1 query problem)
*/
.get('/api/awards/batch/progress', async ({ user, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const awards = await getAllAwards();
// Calculate all awards in parallel
const progressMap = await Promise.all(
awards.map(async (award) => {
const progress = await getAwardProgressDetails(user.id, award.id);
return {
awardId: award.id,
...progress,
};
})
);
return {
success: true,
awards: progressMap,
};
} catch (error) {
logger.error('Failed to fetch batch award progress', { error: error.message, userId: user.id });
set.status = 500;
return {
success: false,
error: 'Failed to fetch award progress',
};
}
})
/**
* GET /api/awards/:awardId/entities
* Get detailed entity breakdown for an award (requires authentication)
*/
.get('/api/awards/:awardId/entities', async ({ user, params, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
const { awardId } = params;
const breakdown = await getAwardEntityBreakdown(user.id, awardId);
if (!breakdown) {
set.status = 404;
return {
success: false,
error: 'Award not found',
};
}
return {
success: true,
...breakdown,
};
})
// Health check endpoint
.get('/api/health', () => ({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
performance: getPerformanceSummary(),
cache: getCacheStats()
}))
/**
* POST /api/logs
* Receive frontend logs and write them to frontend.log file
*/
.post(
'/api/logs',
async ({ body, user, headers }) => {
// Extract context from headers (Elysia provides headers as a plain object)
const userAgent = headers['user-agent'] || 'unknown';
const context = {
userId: user?.id,
userAgent,
timestamp: new Date().toISOString(),
};
// Log each entry
const entries = Array.isArray(body) ? body : [body];
for (const entry of entries) {
logToFrontend(entry.level || 'info', entry.message || 'No message', entry.data || null, context);
}
return { success: true };
},
{
body: t.Union([
t.Object({
level: t.Optional(t.Union([t.Literal('debug'), t.Literal('info'), t.Literal('warn'), t.Literal('error')])),
message: t.String(),
data: t.Optional(t.Any()),
}),
t.Array(
t.Object({
level: t.Optional(t.Union([t.Literal('debug'), t.Literal('info'), t.Literal('warn'), t.Literal('error')])),
message: t.String(),
data: t.Optional(t.Any()),
})
),
]),
}
)
/**
* ================================================================
* ADMIN ROUTES
* ================================================================
* All admin routes require authentication and admin role
*/
/**
* GET /api/admin/stats
* Get system-wide statistics (admin only)
*/
.get('/api/admin/stats', async ({ user, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
const stats = await getSystemStats();
return {
success: true,
stats,
};
} catch (error) {
logger.error('Error fetching system stats', { error: error.message, userId: user.id });
set.status = 500;
return {
success: false,
error: 'Failed to fetch system statistics',
};
}
})
/**
* GET /api/admin/users
* Get all users with statistics (admin only)
*/
.get('/api/admin/users', async ({ user, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
const users = await getUserStats();
return {
success: true,
users,
};
} catch (error) {
logger.error('Error fetching users', { error: error.message, userId: user.id });
set.status = 500;
return {
success: false,
error: 'Failed to fetch users',
};
}
})
/**
* GET /api/admin/users/:userId
* Get detailed information about a specific user (admin only)
*/
.get('/api/admin/users/:userId', async ({ user, params, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
const userId = parseInt(params.userId, 10);
if (isNaN(userId) || userId <= 0) {
set.status = 400;
return { success: false, error: 'Invalid user ID' };
}
try {
const targetUser = await getAllUsers();
const userDetails = targetUser.find(u => u.id === userId);
if (!userDetails) {
set.status = 404;
return { success: false, error: 'User not found' };
}
return {
success: true,
user: userDetails,
};
} catch (error) {
logger.error('Error fetching user details', { error: error.message, userId: user.id });
set.status = 500;
return {
success: false,
error: 'Failed to fetch user details',
};
}
})
/**
* POST /api/admin/users/:userId/role
* Update user admin status (admin only)
*/
.post('/api/admin/users/:userId/role', async ({ user, params, body, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
const targetUserId = parseInt(params.userId, 10);
if (isNaN(targetUserId) || targetUserId <= 0) {
set.status = 400;
return { success: false, error: 'Invalid user ID' };
}
const { isAdmin } = body;
if (typeof isAdmin !== 'boolean') {
set.status = 400;
return { success: false, error: 'isAdmin (boolean) is required' };
}
try {
await changeUserRole(user.id, targetUserId, isAdmin);
return {
success: true,
message: 'User admin status updated successfully',
};
} catch (error) {
logger.error('Error updating user admin status', { error: error.message, userId: user.id });
set.status = 400;
return {
success: false,
error: error.message,
};
}
})
/**
* DELETE /api/admin/users/:userId
* Delete a user (admin only)
*/
.delete('/api/admin/users/:userId', async ({ user, params, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
const targetUserId = parseInt(params.userId, 10);
if (isNaN(targetUserId) || targetUserId <= 0) {
set.status = 400;
return { success: false, error: 'Invalid user ID' };
}
try {
await deleteUser(user.id, targetUserId);
return {
success: true,
message: 'User deleted successfully',
};
} catch (error) {
logger.error('Error deleting user', { error: error.message, userId: user.id });
set.status = 400;
return {
success: false,
error: error.message,
};
}
})
/**
* POST /api/admin/impersonate/:userId
* Start impersonating a user (admin only)
*/
.post('/api/admin/impersonate/:userId', async ({ user, params, jwt, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
const targetUserId = parseInt(params.userId, 10);
if (isNaN(targetUserId) || targetUserId <= 0) {
set.status = 400;
return { success: false, error: 'Invalid user ID' };
}
try {
const targetUser = await impersonateUser(user.id, targetUserId);
// Generate impersonation token with shorter expiration (1 hour)
const exp = Math.floor(Date.now() / 1000) + (60 * 60); // 1 hour from now
const token = await jwt.sign({
userId: targetUser.id,
email: targetUser.email,
callsign: targetUser.callsign,
isAdmin: targetUser.isAdmin,
impersonatedBy: user.id, // Admin ID who started impersonation
exp,
});
return {
success: true,
token,
impersonating: {
userId: targetUser.id,
email: targetUser.email,
callsign: targetUser.callsign,
},
message: `Impersonating ${targetUser.email}`,
};
} catch (error) {
logger.error('Error starting impersonation', { error: error.message, userId: user.id });
set.status = 400;
return {
success: false,
error: error.message,
};
}
})
/**
* POST /api/admin/impersonate/stop
* Stop impersonating and return to admin account (admin only)
*/
.post('/api/admin/impersonate/stop', async ({ user, jwt, body, set }) => {
if (!user || !user.impersonatedBy) {
set.status = 400;
return {
success: false,
error: 'Not currently impersonating a user',
};
}
try {
// Log impersonation stop
await stopImpersonation(user.impersonatedBy, user.id);
// Get admin user details to generate new token
const adminUsers = await getAllUsers();
const adminUser = adminUsers.find(u => u.id === user.impersonatedBy);
if (!adminUser) {
set.status = 500;
return {
success: false,
error: 'Admin account not found',
};
}
// Generate new admin token (24 hours)
const exp = Math.floor(Date.now() / 1000) + (24 * 60 * 60);
const token = await jwt.sign({
userId: adminUser.id,
email: adminUser.email,
callsign: adminUser.callsign,
isAdmin: adminUser.isAdmin,
exp,
});
return {
success: true,
token,
user: adminUser,
message: 'Impersonation stopped. Returned to admin account.',
};
} catch (error) {
logger.error('Error stopping impersonation', { error: error.message });
set.status = 500;
return {
success: false,
error: 'Failed to stop impersonation',
};
}
})
/**
* GET /api/admin/impersonation/status
* Get current impersonation status
*/
.get('/api/admin/impersonation/status', async ({ user }) => {
if (!user) {
return {
success: true,
impersonating: false,
};
}
const isImpersonating = !!user.impersonatedBy;
return {
success: true,
impersonating: isImpersonating,
impersonatedBy: user.impersonatedBy,
};
})
/**
* GET /api/admin/actions
* Get admin actions log (admin only)
*/
.get('/api/admin/actions', async ({ user, set, query }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
const limit = parseInt(query.limit || '50', 10);
const offset = parseInt(query.offset || '0', 10);
try {
const actions = await getAdminActions(null, { limit, offset });
return {
success: true,
actions,
};
} catch (error) {
logger.error('Error fetching admin actions', { error: error.message, userId: user.id });
set.status = 500;
return {
success: false,
error: 'Failed to fetch admin actions',
};
}
})
/**
* GET /api/admin/actions/my
* Get current admin's action log (admin only)
*/
.get('/api/admin/actions/my', async ({ user, set, query }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
const limit = parseInt(query.limit || '50', 10);
const offset = parseInt(query.offset || '0', 10);
try {
const actions = await getAdminActions(user.id, { limit, offset });
return {
success: true,
actions,
};
} catch (error) {
logger.error('Error fetching admin actions', { error: error.message, userId: user.id });
set.status = 500;
return {
success: false,
error: 'Failed to fetch admin actions',
};
}
})
// Serve static files and SPA fallback for all non-API routes
.get('/*', ({ request }) => {
const url = new URL(request.url);
const pathname = url.pathname;
// Don't intercept API routes
if (pathname.startsWith('/api')) {
return new Response('Not found', { status: 404 });
}
// Check for common missing assets before trying to open files
// This prevents Elysia from trying to get file size of non-existent files
const commonMissingFiles = ['/favicon.ico', '/robots.txt'];
if (commonMissingFiles.includes(pathname)) {
return new Response('Not found', { status: 404 });
}
// Handle SvelteKit assets path - replace %sveltekit.assets% with the assets directory
if (pathname.startsWith('/%sveltekit.assets%/')) {
// Extract the actual file path after %sveltekit.assets%/
const assetPath = pathname.replace('/%sveltekit.assets%/', '');
// SECURITY: Validate path before serving
const assetsDirPath = `_app/immutable/assets/${assetPath}`;
const rootPath = assetPath;
if (isPathSafe(assetsDirPath)) {
try {
const assetsPath = `src/frontend/build/${assetsDirPath}`;
const file = Bun.file(assetsPath);
const exists = file.exists();
if (exists) {
return new Response(file);
}
} catch (err) {
// Fall through to 404
}
}
// If not in assets, try root directory (with path validation)
if (isPathSafe(rootPath)) {
try {
const rootFile = Bun.file(`src/frontend/build/${rootPath}`);
const rootExists = rootFile.exists();
if (rootExists) {
return new Response(rootFile);
}
} catch (err) {
// Fall through to 404
}
}
return new Response('Not found', { status: 404 });
}
// Try to serve the file from the build directory
// Remove leading slash for file path
const filePath = pathname === '/' ? '/index.html' : pathname;
// SECURITY: Validate path is within build directory before serving
if (!isPathSafe(filePath.substring(1))) { // Remove leading slash for relative path
logger.warn('Path traversal attempt blocked', { pathname });
return new Response('Not found', { status: 404 });
}
try {
const fullPath = `src/frontend/build${filePath}`;
// For paths without extensions or directories, use SPA fallback immediately
// This prevents errors when trying to open directories as files
const ext = filePath.split('.').pop();
const hasExtension = ext !== filePath && ext.length <= 5; // Simple check for file extension
if (!hasExtension) {
// No extension means it's a route, not a file - serve index.html
const indexFile = Bun.file('src/frontend/build/index.html');
return new Response(indexFile, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
});
}
// Try to serve actual files (with extensions)
const file = Bun.file(fullPath);
const exists = file.exists();
if (exists) {
// Determine content type
const contentTypes = {
'js': 'application/javascript',
'css': 'text/css',
'html': 'text/html; charset=utf-8',
'json': 'application/json',
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'svg': 'image/svg+xml',
'ico': 'image/x-icon',
'woff': 'font/woff',
'woff2': 'font/woff2',
'ttf': 'font/ttf',
};
const headers = {};
if (contentTypes[ext]) {
headers['Content-Type'] = contentTypes[ext];
}
// Cache headers
if (ext === 'html') {
headers['Cache-Control'] = 'no-cache, no-store, must-revalidate';
} else {
headers['Cache-Control'] = 'public, max-age=86400';
}
return new Response(file, { headers });
}
} catch (err) {
// File not found or error, fall through to SPA fallback
logger.debug('Error serving static file, falling back to SPA', { path: pathname, error: err.message });
}
// SPA fallback - serve index.html for all other routes
try {
const indexFile = Bun.file('src/frontend/build/index.html');
return new Response(indexFile, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
});
} catch (err) {
logger.error('Frontend build not found', { error: err.message });
return new Response(
'<!DOCTYPE html><html><head><title>Quickawards - Unavailable</title></head>' +
'<body style="font-family: system-ui; max-width: 600px; margin: 100px auto; padding: 20px;">' +
'<h1>Service Temporarily Unavailable</h1>' +
'<p>The frontend application is not currently available. This usually means the application is being updated or restarted.</p>' +
'<p>Please try refreshing the page in a few moments.</p></body></html>',
{ status: 503, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
);
}
})
// Start server - uses PORT environment variable if set, otherwise defaults to 3001
.listen(process.env.PORT || 3001);
logger.info('Server started', {
port: process.env.PORT || 3001,
nodeEnv: process.env.NODE_ENV || 'unknown',
logLevel: LOG_LEVEL,
});