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>
1557 lines
42 KiB
JavaScript
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,
|
|
});
|