From 3131b8e6849866b7ea8c6587b420beb7a731acb8 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 12 May 2026 13:39:23 -0500 Subject: [PATCH] fix(lotw): filter QSOs by cert's allowed date range before upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoTW silently rejects any QSO whose date falls outside the cert's qso_start_date / qso_end_date window (ARRL X.509 OIDs .2 / .3), even when the cert itself is within its notBefore/notAfter validity. Wavelog enforces this server-side filter before signing (application/controllers/Lotw.php:256-264 + get_lotw_qsos_to_upload); nextlog wasn't reading those extensions at all and was relying on LoTW to reject the file. LoTW's rejection isn't visible in the upload response — the .tq8 still queues with — so out-of-range QSOs got marked lotw_qsl_sent='Y' locally while LoTW quietly dropped them. Changes: - parseP12 reads ARRL OIDs 1.3.6.1.4.1.12348.1.{2,3}, parsed into qsoStartDate / qsoEndDate on the ParsedP12 result. Generalized the DER extraction into readArrlPrintableExt to share with the DXCC field (OID .4). - Added isQsoWithinCertDateRange helper exported from lib/lotw.ts. End-of-day handling is inclusive through 23:59:59.999 UTC of the end date, matching wavelog's `qso_end_date . ' 23:59:59'`. - /api/lotw/upload pre-reads cert metadata via readCertMetadata, splits contacts into in-range / out-of-range / unsupported-prop-mode buckets, and reports the count + the actual date window in the upload log error_message ("Skipped N QSOs outside cert's QSO date range (YYYY-MM-DD to YYYY-MM-DD)") so it shows up on /lotw. - /api/lotw/upload-contact rejects the single-QSO upload up front with the same human-readable date-range error rather than queuing a file LoTW will drop. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/lotw/upload-contact/route.ts | 39 +++++++- src/app/api/lotw/upload/route.ts | 91 +++++++++++++++--- src/lib/lotw.ts | 115 +++++++++++++++++++---- 3 files changed, 210 insertions(+), 35 deletions(-) diff --git a/src/app/api/lotw/upload-contact/route.ts b/src/app/api/lotw/upload-contact/route.ts index 782e0ab..82b2d48 100644 --- a/src/app/api/lotw/upload-contact/route.ts +++ b/src/app/api/lotw/upload-contact/route.ts @@ -3,7 +3,13 @@ import { NextRequest, NextResponse } from 'next/server'; import { verifyToken } from '@/lib/auth'; import { query } from '@/lib/db'; -import { buildSignedTq8, normalizeCallsign, decryptString } from '@/lib/lotw'; +import { + buildSignedTq8, + normalizeCallsign, + decryptString, + readCertMetadata, + isQsoWithinCertDateRange, +} from '@/lib/lotw'; import { ContactWithLoTW, LotwQso, LotwStationProfile } from '@/types/lotw'; const LOTW_UNSUPPORTED_PROP_MODES = new Set(['INTERNET', 'RPT']); @@ -102,6 +108,37 @@ export async function POST(request: NextRequest) { try { p12Password = decryptString(certificate.p12_password); } catch {} } + // Reject the upload up front if the QSO date isn't covered by this cert's + // ARRL qso_first_date / qso_end_date extensions. LoTW would otherwise + // queue the file, then silently drop the QSO server-side. + try { + const meta = readCertMetadata(certificate.p12_cert, p12Password); + if ( + !isQsoWithinCertDateRange( + new Date(contact.datetime), + meta.qsoStartDate, + meta.qsoEndDate + ) + ) { + const s = meta.qsoStartDate?.toISOString().slice(0, 10) ?? '−∞'; + const e = meta.qsoEndDate?.toISOString().slice(0, 10) ?? '+∞'; + return NextResponse.json( + { + success: false, + error: `QSO date ${new Date(contact.datetime) + .toISOString() + .slice(0, 10)} is outside this cert's QSO date range (${s} to ${e}). Renew the LoTW certificate or use one whose range covers this date.`, + }, + { status: 400 } + ); + } + } catch (metaError) { + console.error('[LoTW Upload-Contact] Failed to read cert metadata:', metaError); + // Fall through — if we can't read the cert window we let the upload + // proceed and rely on LoTW to reject (rare; cert was already validated + // by parseP12 on upload). + } + const stationProfile: LotwStationProfile = { callsign: normalizeCallsign(contact.station_callsign), dxcc: contact.dxcc_entity_code, diff --git a/src/app/api/lotw/upload/route.ts b/src/app/api/lotw/upload/route.ts index 97b54c4..fa3e4b8 100644 --- a/src/app/api/lotw/upload/route.ts +++ b/src/app/api/lotw/upload/route.ts @@ -8,6 +8,8 @@ import { generateAdifHash, normalizeCallsign, decryptString, + readCertMetadata, + isQsoWithinCertDateRange, } from '@/lib/lotw'; import { LotwUploadRequest, @@ -156,17 +158,46 @@ export async function POST(request: NextRequest) { const contactsResult = await query(contactQuery, queryParams); const allContacts: ContactWithLoTW[] = contactsResult.rows; + // Read the cert's qso_start_date / qso_end_date up front so we can filter + // out-of-range QSOs without having to ingest a parse failure mid-signing. + // LoTW silently discards QSOs whose date is outside the cert's window + // (these are the rejection emails that say "QSO date is outside the + // QSL'able date range for this certificate"), so this filter prevents the + // upload from "succeeding" while LoTW drops the file on its end. + let certMetadata: ReturnType | undefined; + try { + certMetadata = readCertMetadata(certificate.p12_cert, p12Password); + } catch (metaError) { + console.error('[LoTW Upload] Failed to read cert metadata:', metaError); + } + const certWindow = certMetadata + ? { start: certMetadata.qsoStartDate, end: certMetadata.qsoEndDate } + : { start: undefined, end: undefined }; + // Filter out QSOs whose prop_mode LoTW doesn't accept; flag them as 'I' so - // they don't keep cycling through future upload passes. + // they don't keep cycling through future upload passes. Also filter out + // QSOs outside the cert's allowed QSO date window — those would be + // silently dropped by LoTW even though our .tq8 is otherwise valid. const skipped: ContactWithLoTW[] = []; + const outOfRange: ContactWithLoTW[] = []; const contacts: ContactWithLoTW[] = []; for (const c of allContacts) { const propMode = (c.prop_mode || '').toUpperCase(); if (propMode && LOTW_UNSUPPORTED_PROP_MODES.has(propMode)) { skipped.push(c); - } else { - contacts.push(c); + continue; } + if ( + !isQsoWithinCertDateRange( + new Date(c.datetime), + certWindow.start, + certWindow.end + ) + ) { + outOfRange.push(c); + continue; + } + contacts.push(c); } if (skipped.length > 0) { await query( @@ -176,22 +207,42 @@ export async function POST(request: NextRequest) { ); } + const formatCertWindow = () => { + if (!certWindow.start && !certWindow.end) return 'unknown'; + const s = certWindow.start + ? certWindow.start.toISOString().slice(0, 10) + : '−∞'; + const e = certWindow.end + ? certWindow.end.toISOString().slice(0, 10) + : '+∞'; + return `${s} to ${e}`; + }; + if (contacts.length === 0) { + const parts: string[] = []; + if (skipped.length > 0) + parts.push(`${skipped.length} unsupported prop_mode`); + if (outOfRange.length > 0) + parts.push( + `${outOfRange.length} outside cert's QSO date range (${formatCertWindow()})` + ); + const reason = parts.length + ? `No upload-eligible contacts (skipped ${parts.join(', ')})` + : 'No contacts found for upload'; + await query( `UPDATE lotw_upload_logs SET status = 'completed', completed_at = NOW(), qso_count = 0, error_message = $1 WHERE id = $2`, - [skipped.length > 0 - ? `No upload-eligible contacts (skipped ${skipped.length} unsupported prop_mode QSOs)` - : 'No contacts found for upload', uploadLogId] + [reason, uploadLogId] ); const response: LotwUploadResponse = { success: true, upload_log_id: uploadLogId, qso_count: 0, - error_message: 'No contacts found for upload' + error_message: reason, }; return NextResponse.json(response); @@ -326,26 +377,36 @@ export async function POST(request: NextRequest) { // Mark contacts as uploaded to LoTW const contactIds = contacts.map(c => c.id); await query( - `UPDATE contacts - SET lotw_qsl_sent = 'Y', updated_at = NOW() + `UPDATE contacts + SET lotw_qsl_sent = 'Y', updated_at = NOW() WHERE id = ANY($1)`, [contactIds] ); + // Surface the out-of-range count alongside the success log so it's + // visible on the /lotw upload log table — those QSOs aren't on LoTW + // and the operator needs to know either to renew the cert or to fix + // the QSO dates. + const partialNotice = outOfRange.length + ? `Skipped ${outOfRange.length} QSO${outOfRange.length === 1 ? '' : 's'} outside cert's QSO date range (${formatCertWindow()})` + : null; + // Update upload log as completed await query( - `UPDATE lotw_upload_logs - SET status = 'completed', completed_at = NOW(), - success_count = $1, lotw_response = $2 - WHERE id = $3`, - [contacts.length, lotwResponse, uploadLogId] + `UPDATE lotw_upload_logs + SET status = 'completed', completed_at = NOW(), + success_count = $1, lotw_response = $2, + error_message = $3 + WHERE id = $4`, + [contacts.length, lotwResponse, partialNotice, uploadLogId] ); const response: LotwUploadResponse = { success: true, upload_log_id: uploadLogId, qso_count: contacts.length, - lotw_response: lotwResponse + lotw_response: lotwResponse, + ...(partialNotice ? { error_message: partialNotice } : {}), }; return NextResponse.json(response); diff --git a/src/lib/lotw.ts b/src/lib/lotw.ts index df595dc..de74113 100644 --- a/src/lib/lotw.ts +++ b/src/lib/lotw.ts @@ -263,6 +263,10 @@ function parseLoTWDateTime(qsoDate: string, timeOn: string): Date { // ===================================================================== const TQSL_IDENT = 'TQSL V2.8.2 Lib: V2.6 Config: V11.34 AllowDupes: false'; +// ARRL's private X.509 extensions in a LoTW certificate. +// Reference: https://oidref.com/1.3.6.1.4.1.12348.1 +const ARRL_QSO_FIRST_DATE_OID = '1.3.6.1.4.1.12348.1.2'; +const ARRL_QSO_END_DATE_OID = '1.3.6.1.4.1.12348.1.3'; const ARRL_DXCC_OID = '1.3.6.1.4.1.12348.1.4'; // Internal: the parsed P12 we feed to signing. @@ -271,8 +275,48 @@ interface ParsedP12 { certPem: string; certPemBody: string; // BEGIN/END stripped, internal newlines preserved certSerial: string; // hex, lowercased; for CRL queries - certDxcc?: number; // from ARRL OID 1.3.6.1.4.1.12348.1.4 + certDxcc?: number; // from ARRL OID .4 certNotAfter?: Date; + // QSO date range encoded in ARRL OIDs .2 / .3 — LoTW silently rejects any + // QSO whose date falls outside this window, even if the cert itself is + // within its X.509 validity. Mirrors wavelog's preflight filter. + qsoStartDate?: Date; + qsoEndDate?: Date; +} + +// Read a PrintableString value out of an ARRL private extension. The forge +// `value` field for unknown extensions is a binary string holding the raw +// DER-encoded ASN.1 value; we parse it and pull the inner string. +function readArrlPrintableExt( + certBag: forge.pki.Certificate, + oid: string +): string | undefined { + try { + type ForgeExtension = { id: string; value?: unknown }; + const certExts = + (certBag as unknown as { extensions?: ForgeExtension[] }).extensions ?? + []; + const ext = certExts.find(e => e?.id === oid); + if (!ext || typeof ext.value !== 'string') return undefined; + const inner = forge.asn1.fromDer(ext.value); + const innerValue = (inner as { value: unknown }).value; + return typeof innerValue === 'string' ? innerValue : undefined; + } catch { + return undefined; + } +} + +// ARRL stores the QSO-date-range bounds as compact strings — historically +// "YYYYMMDD" but newer certs may use "YYYY-MM-DD". Accept both, return a +// UTC midnight Date. End-of-day handling (i.e. inclusive end date) lives +// at the call site, not here. +function parseArrlDateString(raw: string | undefined): Date | undefined { + if (!raw) return undefined; + const m = raw.match(/^(\d{4})-?(\d{2})-?(\d{2})/); + if (!m) return undefined; + const [, y, mm, dd] = m; + const d = new Date(Date.UTC(parseInt(y, 10), parseInt(mm, 10) - 1, parseInt(dd, 10))); + return Number.isNaN(d.getTime()) ? undefined : d; } function parseP12(buf: Buffer, password: string): ParsedP12 { @@ -319,24 +363,21 @@ function parseP12(buf: Buffer, password: string): ParsedP12 { // forge's getExtension types accept `id: number` only, but X.509 extension // OIDs are dotted strings — search the .extensions array directly instead. let certDxcc: number | undefined; - try { - type ForgeExtension = { id: string; value?: unknown }; - const certExts = (certBag.cert as unknown as { extensions?: ForgeExtension[] }).extensions ?? []; - const ext = certExts.find(e => e?.id === ARRL_DXCC_OID); - if (ext && typeof ext.value === 'string') { - // The extension value is DER-encoded; parse to extract the printable string. - const inner = forge.asn1.fromDer(ext.value); - const innerValue = (inner as { value: unknown }).value; - if (typeof innerValue === 'string') { - const n = parseInt(innerValue, 10); - if (!Number.isNaN(n)) certDxcc = n; - } - } - } catch { - // Best-effort — if we can't read DXCC from the cert, the caller's station - // profile DXCC is used. Do not fail the upload over this. + const dxccRaw = readArrlPrintableExt(certBag.cert, ARRL_DXCC_OID); + if (dxccRaw) { + const n = parseInt(dxccRaw, 10); + if (!Number.isNaN(n)) certDxcc = n; } + // QSO-date-range bounds from the same ARRL extension family. LoTW silently + // discards any QSO whose date is outside [qsoStartDate, qsoEndDate]. + const qsoStartDate = parseArrlDateString( + readArrlPrintableExt(certBag.cert, ARRL_QSO_FIRST_DATE_OID) + ); + const qsoEndDate = parseArrlDateString( + readArrlPrintableExt(certBag.cert, ARRL_QSO_END_DATE_OID) + ); + // Serial as hex (lowercase, no leading zeros) — matches wavelog's CRL format. const certSerial = (certBag.cert.serialNumber || '').toLowerCase(); @@ -348,7 +389,16 @@ function parseP12(buf: Buffer, password: string): ParsedP12 { certNotAfter = validity.notAfter; } - return { privateKeyPem, certPem, certPemBody, certSerial, certDxcc, certNotAfter }; + return { + privateKeyPem, + certPem, + certPemBody, + certSerial, + certDxcc, + certNotAfter, + qsoStartDate, + qsoEndDate, + }; } // Format a frequency in MHz the way TQSL does — trim trailing zeros, @@ -624,9 +674,36 @@ export function readCertMetadata(p12: Buffer, password: string): { serial: string; notAfter?: Date; dxcc?: number; + qsoStartDate?: Date; + qsoEndDate?: Date; } { const parsed = parseP12(p12, password); - return { serial: parsed.certSerial, notAfter: parsed.certNotAfter, dxcc: parsed.certDxcc }; + return { + serial: parsed.certSerial, + notAfter: parsed.certNotAfter, + dxcc: parsed.certDxcc, + qsoStartDate: parsed.qsoStartDate, + qsoEndDate: parsed.qsoEndDate, + }; +} + +// Check whether a QSO datetime falls within the cert's allowed QSO date range. +// LoTW silently discards out-of-range QSOs server-side, so we filter them +// before signing and report them back to the caller. End is inclusive through +// 23:59:59.999 UTC of qsoEndDate (mirroring wavelog's `qso_end_date . ' 23:59:59'`). +export function isQsoWithinCertDateRange( + qsoDatetime: Date, + qsoStartDate: Date | undefined, + qsoEndDate: Date | undefined +): boolean { + const t = qsoDatetime.getTime(); + if (qsoStartDate && t < qsoStartDate.getTime()) return false; + if (qsoEndDate) { + // Inclusive end-of-day in UTC. + const endOfDay = qsoEndDate.getTime() + 24 * 60 * 60 * 1000 - 1; + if (t > endOfDay) return false; + } + return true; } // Generate SHA-256 hash of ADIF content for tracking