security: implement multiple security hardening fixes
This commit addresses several critical and high-severity security vulnerabilities identified in a comprehensive security audit: Critical Fixes: - Enforce JWT_SECRET in production (throws error if not set) - Add JWT token expiration (24 hours) - Implement path traversal protection for static file serving - Add rate limiting to authentication endpoints (5/10 req per minute) - Fix CORS to never allow all origins (even in development) High/Medium Fixes: - Add comprehensive security headers (CSP, HSTS, X-Frame-Options, etc.) - Implement stricter email validation (RFC 5321 compliant) - Add input sanitization for search parameters (length limit, wildcard removal) - Improve job/QSO ID validation (range checks, safe integer validation) Files modified: - src/backend/config.js: JWT secret enforcement - src/backend/index.js: JWT expiration, security headers, rate limiting, email validation, path traversal protection, CORS hardening, ID validation - src/backend/services/lotw.service.js: search input sanitization Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,16 @@ const __dirname = dirname(__filename);
|
|||||||
|
|
||||||
const isDevelopment = process.env.NODE_ENV !== 'production';
|
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||||
|
|
||||||
export const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
// SECURITY: Require JWT_SECRET in production - no fallback for security
|
||||||
|
// This prevents JWT token forgery if environment variable is not set
|
||||||
|
if (!process.env.JWT_SECRET && !isDevelopment) {
|
||||||
|
throw new Error(
|
||||||
|
'FATAL: JWT_SECRET environment variable must be set in production. ' +
|
||||||
|
'Generate one with: openssl rand -base64 32'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-key-change-in-production';
|
||||||
export const LOG_LEVEL = process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'info');
|
export const LOG_LEVEL = process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'info');
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Elysia, t } from 'elysia';
|
import { Elysia, t } from 'elysia';
|
||||||
import { cors } from '@elysiajs/cors';
|
import { cors } from '@elysiajs/cors';
|
||||||
import { jwt } from '@elysiajs/jwt';
|
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 { JWT_SECRET, logger, LOG_LEVEL, logToFrontend } from './config.js';
|
||||||
import {
|
import {
|
||||||
registerUser,
|
registerUser,
|
||||||
@@ -33,12 +35,116 @@ import {
|
|||||||
* Serves API routes and static frontend files
|
* Serves API routes and static frontend files
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Get allowed origins from environment or allow all in development
|
// 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
|
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS
|
||||||
? process.env.ALLOWED_ORIGINS.split(',')
|
? process.env.ALLOWED_ORIGINS.split(',')
|
||||||
: process.env.NODE_ENV === 'production'
|
: process.env.NODE_ENV === 'production'
|
||||||
? [process.env.VITE_APP_URL || 'https://awards.dj7nt.de']
|
? [process.env.VITE_APP_URL || 'https://awards.dj7nt.de']
|
||||||
: true; // Allow all in development
|
: [process.env.VITE_APP_URL || 'http://localhost:5173']; // Default dev origin
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
// Enable CORS for frontend communication
|
// Enable CORS for frontend communication
|
||||||
@@ -80,6 +186,46 @@ const app = new Elysia()
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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
|
// Request logging middleware
|
||||||
.onRequest(({ request, params }) => {
|
.onRequest(({ request, params }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
@@ -124,7 +270,31 @@ const app = new Elysia()
|
|||||||
*/
|
*/
|
||||||
.post(
|
.post(
|
||||||
'/api/auth/register',
|
'/api/auth/register',
|
||||||
async ({ body, jwt, set }) => {
|
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
|
// Create user
|
||||||
const user = await registerUser(body);
|
const user = await registerUser(body);
|
||||||
|
|
||||||
@@ -136,11 +306,13 @@ const app = new Elysia()
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT token
|
// 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({
|
const token = await jwt.sign({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
callsign: user.callsign,
|
callsign: user.callsign,
|
||||||
|
exp,
|
||||||
});
|
});
|
||||||
|
|
||||||
set.status = 201;
|
set.status = 201;
|
||||||
@@ -175,7 +347,22 @@ const app = new Elysia()
|
|||||||
*/
|
*/
|
||||||
.post(
|
.post(
|
||||||
'/api/auth/login',
|
'/api/auth/login',
|
||||||
async ({ body, jwt, set }) => {
|
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
|
// Authenticate user
|
||||||
const user = await authenticateUser(body.email, body.password);
|
const user = await authenticateUser(body.email, body.password);
|
||||||
|
|
||||||
@@ -187,11 +374,13 @@ const app = new Elysia()
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT token
|
// 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({
|
const token = await jwt.sign({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
callsign: user.callsign,
|
callsign: user.callsign,
|
||||||
|
exp,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -389,11 +578,12 @@ const app = new Elysia()
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const jobId = parseInt(params.jobId);
|
// SECURITY: Validate job ID to prevent injection and DoS
|
||||||
if (isNaN(jobId)) {
|
if (!isValidJobId(params.jobId)) {
|
||||||
set.status = 400;
|
set.status = 400;
|
||||||
return { success: false, error: 'Invalid job ID' };
|
return { success: false, error: 'Invalid job ID' };
|
||||||
}
|
}
|
||||||
|
const jobId = parseInt(params.jobId, 10);
|
||||||
|
|
||||||
const job = await getJobStatus(jobId);
|
const job = await getJobStatus(jobId);
|
||||||
if (!job) {
|
if (!job) {
|
||||||
@@ -498,11 +688,12 @@ const app = new Elysia()
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const jobId = parseInt(params.jobId);
|
// SECURITY: Validate job ID to prevent injection and DoS
|
||||||
if (isNaN(jobId)) {
|
if (!isValidJobId(params.jobId)) {
|
||||||
set.status = 400;
|
set.status = 400;
|
||||||
return { success: false, error: 'Invalid job ID' };
|
return { success: false, error: 'Invalid job ID' };
|
||||||
}
|
}
|
||||||
|
const jobId = parseInt(params.jobId, 10);
|
||||||
|
|
||||||
const result = await cancelJob(jobId, user.id);
|
const result = await cancelJob(jobId, user.id);
|
||||||
|
|
||||||
@@ -576,11 +767,12 @@ const app = new Elysia()
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const qsoId = parseInt(params.id);
|
// SECURITY: Validate QSO ID to prevent injection and DoS
|
||||||
if (isNaN(qsoId)) {
|
if (!isValidQsoId(params.id)) {
|
||||||
set.status = 400;
|
set.status = 400;
|
||||||
return { success: false, error: 'Invalid QSO ID' };
|
return { success: false, error: 'Invalid QSO ID' };
|
||||||
}
|
}
|
||||||
|
const qsoId = parseInt(params.id, 10);
|
||||||
|
|
||||||
const qso = await getQSOById(user.id, qsoId);
|
const qso = await getQSOById(user.id, qsoId);
|
||||||
|
|
||||||
@@ -844,28 +1036,35 @@ const app = new Elysia()
|
|||||||
// Extract the actual file path after %sveltekit.assets%/
|
// Extract the actual file path after %sveltekit.assets%/
|
||||||
const assetPath = pathname.replace('/%sveltekit.assets%/', '');
|
const assetPath = pathname.replace('/%sveltekit.assets%/', '');
|
||||||
|
|
||||||
try {
|
// SECURITY: Validate path before serving
|
||||||
// Try to serve from assets directory first
|
const assetsDirPath = `_app/immutable/assets/${assetPath}`;
|
||||||
const assetsPath = `src/frontend/build/_app/immutable/assets/${assetPath}`;
|
const rootPath = assetPath;
|
||||||
const file = Bun.file(assetsPath);
|
|
||||||
const exists = file.exists();
|
|
||||||
|
|
||||||
if (exists) {
|
if (isPathSafe(assetsDirPath)) {
|
||||||
return new Response(file);
|
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
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
// Fall through to 404
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in assets, try root directory
|
// If not in assets, try root directory (with path validation)
|
||||||
try {
|
if (isPathSafe(rootPath)) {
|
||||||
const rootFile = Bun.file(`src/frontend/build/${assetPath}`);
|
try {
|
||||||
const rootExists = rootFile.exists();
|
const rootFile = Bun.file(`src/frontend/build/${rootPath}`);
|
||||||
if (rootExists) {
|
const rootExists = rootFile.exists();
|
||||||
return new Response(rootFile);
|
if (rootExists) {
|
||||||
|
return new Response(rootFile);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Fall through to 404
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
// Fall through to 404
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response('Not found', { status: 404 });
|
return new Response('Not found', { status: 404 });
|
||||||
@@ -875,6 +1074,12 @@ const app = new Elysia()
|
|||||||
// Remove leading slash for file path
|
// Remove leading slash for file path
|
||||||
const filePath = pathname === '/' ? '/index.html' : pathname;
|
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 {
|
try {
|
||||||
const fullPath = `src/frontend/build${filePath}`;
|
const fullPath = `src/frontend/build${filePath}`;
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,35 @@ const MAX_RETRIES = 30;
|
|||||||
const RETRY_DELAY = 10000;
|
const RETRY_DELAY = 10000;
|
||||||
const REQUEST_TIMEOUT = 60000;
|
const REQUEST_TIMEOUT = 60000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SECURITY: Sanitize search input to prevent injection and DoS
|
||||||
|
* Limits length and removes potentially harmful characters
|
||||||
|
*/
|
||||||
|
function sanitizeSearchInput(searchTerm) {
|
||||||
|
if (!searchTerm || typeof searchTerm !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim whitespace
|
||||||
|
let sanitized = searchTerm.trim();
|
||||||
|
|
||||||
|
// Limit length (DoS prevention)
|
||||||
|
const MAX_SEARCH_LENGTH = 100;
|
||||||
|
if (sanitized.length > MAX_SEARCH_LENGTH) {
|
||||||
|
sanitized = sanitized.substring(0, MAX_SEARCH_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove potentially dangerous SQL pattern wildcards from user input
|
||||||
|
// We'll add our own wildcards for the LIKE query
|
||||||
|
// Note: Drizzle ORM escapes parameters, but this adds defense-in-depth
|
||||||
|
sanitized = sanitized.replace(/[%_\\]/g, '');
|
||||||
|
|
||||||
|
// Remove null bytes and other control characters
|
||||||
|
sanitized = sanitized.replace(/[\x00-\x1F\x7F]/g, '');
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if LoTW response indicates the report is still being prepared
|
* Check if LoTW response indicates the report is still being prepared
|
||||||
*/
|
*/
|
||||||
@@ -419,12 +448,16 @@ export async function getUserQSOs(userId, filters = {}, options = {}) {
|
|||||||
|
|
||||||
// Search filter: callsign, entity, or grid
|
// Search filter: callsign, entity, or grid
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
const searchTerm = `%${filters.search}%`;
|
// SECURITY: Sanitize search input to prevent injection
|
||||||
conditions.push(or(
|
const sanitized = sanitizeSearchInput(filters.search);
|
||||||
like(qsos.callsign, searchTerm),
|
if (sanitized) {
|
||||||
like(qsos.entity, searchTerm),
|
const searchTerm = `%${sanitized}%`;
|
||||||
like(qsos.grid, searchTerm)
|
conditions.push(or(
|
||||||
));
|
like(qsos.callsign, searchTerm),
|
||||||
|
like(qsos.entity, searchTerm),
|
||||||
|
like(qsos.grid, searchTerm)
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use SQL COUNT for efficient pagination (avoids loading all QSOs into memory)
|
// Use SQL COUNT for efficient pagination (avoids loading all QSOs into memory)
|
||||||
|
|||||||
Reference in New Issue
Block a user