Skip to content

Commit 7e99cbe

Browse files
patrickrbclaude
andauthored
fix(lotw): filter QSOs by cert's allowed date range before upload (#214)
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 <!-- .UPL. accepted --> — 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) <noreply@anthropic.com>
1 parent ab49fe8 commit 7e99cbe

3 files changed

Lines changed: 210 additions & 35 deletions

File tree

src/app/api/lotw/upload-contact/route.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
import { NextRequest, NextResponse } from 'next/server';
44
import { verifyToken } from '@/lib/auth';
55
import { query } from '@/lib/db';
6-
import { buildSignedTq8, normalizeCallsign, decryptString } from '@/lib/lotw';
6+
import {
7+
buildSignedTq8,
8+
normalizeCallsign,
9+
decryptString,
10+
readCertMetadata,
11+
isQsoWithinCertDateRange,
12+
} from '@/lib/lotw';
713
import { ContactWithLoTW, LotwQso, LotwStationProfile } from '@/types/lotw';
814

915
const LOTW_UNSUPPORTED_PROP_MODES = new Set(['INTERNET', 'RPT']);
@@ -102,6 +108,37 @@ export async function POST(request: NextRequest) {
102108
try { p12Password = decryptString(certificate.p12_password); } catch {}
103109
}
104110

111+
// Reject the upload up front if the QSO date isn't covered by this cert's
112+
// ARRL qso_first_date / qso_end_date extensions. LoTW would otherwise
113+
// queue the file, then silently drop the QSO server-side.
114+
try {
115+
const meta = readCertMetadata(certificate.p12_cert, p12Password);
116+
if (
117+
!isQsoWithinCertDateRange(
118+
new Date(contact.datetime),
119+
meta.qsoStartDate,
120+
meta.qsoEndDate
121+
)
122+
) {
123+
const s = meta.qsoStartDate?.toISOString().slice(0, 10) ?? '−∞';
124+
const e = meta.qsoEndDate?.toISOString().slice(0, 10) ?? '+∞';
125+
return NextResponse.json(
126+
{
127+
success: false,
128+
error: `QSO date ${new Date(contact.datetime)
129+
.toISOString()
130+
.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.`,
131+
},
132+
{ status: 400 }
133+
);
134+
}
135+
} catch (metaError) {
136+
console.error('[LoTW Upload-Contact] Failed to read cert metadata:', metaError);
137+
// Fall through — if we can't read the cert window we let the upload
138+
// proceed and rely on LoTW to reject (rare; cert was already validated
139+
// by parseP12 on upload).
140+
}
141+
105142
const stationProfile: LotwStationProfile = {
106143
callsign: normalizeCallsign(contact.station_callsign),
107144
dxcc: contact.dxcc_entity_code,

src/app/api/lotw/upload/route.ts

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
generateAdifHash,
99
normalizeCallsign,
1010
decryptString,
11+
readCertMetadata,
12+
isQsoWithinCertDateRange,
1113
} from '@/lib/lotw';
1214
import {
1315
LotwUploadRequest,
@@ -156,17 +158,46 @@ export async function POST(request: NextRequest) {
156158
const contactsResult = await query(contactQuery, queryParams);
157159
const allContacts: ContactWithLoTW[] = contactsResult.rows;
158160

161+
// Read the cert's qso_start_date / qso_end_date up front so we can filter
162+
// out-of-range QSOs without having to ingest a parse failure mid-signing.
163+
// LoTW silently discards QSOs whose date is outside the cert's window
164+
// (these are the rejection emails that say "QSO date is outside the
165+
// QSL'able date range for this certificate"), so this filter prevents the
166+
// upload from "succeeding" while LoTW drops the file on its end.
167+
let certMetadata: ReturnType<typeof readCertMetadata> | undefined;
168+
try {
169+
certMetadata = readCertMetadata(certificate.p12_cert, p12Password);
170+
} catch (metaError) {
171+
console.error('[LoTW Upload] Failed to read cert metadata:', metaError);
172+
}
173+
const certWindow = certMetadata
174+
? { start: certMetadata.qsoStartDate, end: certMetadata.qsoEndDate }
175+
: { start: undefined, end: undefined };
176+
159177
// Filter out QSOs whose prop_mode LoTW doesn't accept; flag them as 'I' so
160-
// they don't keep cycling through future upload passes.
178+
// they don't keep cycling through future upload passes. Also filter out
179+
// QSOs outside the cert's allowed QSO date window — those would be
180+
// silently dropped by LoTW even though our .tq8 is otherwise valid.
161181
const skipped: ContactWithLoTW[] = [];
182+
const outOfRange: ContactWithLoTW[] = [];
162183
const contacts: ContactWithLoTW[] = [];
163184
for (const c of allContacts) {
164185
const propMode = (c.prop_mode || '').toUpperCase();
165186
if (propMode && LOTW_UNSUPPORTED_PROP_MODES.has(propMode)) {
166187
skipped.push(c);
167-
} else {
168-
contacts.push(c);
188+
continue;
169189
}
190+
if (
191+
!isQsoWithinCertDateRange(
192+
new Date(c.datetime),
193+
certWindow.start,
194+
certWindow.end
195+
)
196+
) {
197+
outOfRange.push(c);
198+
continue;
199+
}
200+
contacts.push(c);
170201
}
171202
if (skipped.length > 0) {
172203
await query(
@@ -176,22 +207,42 @@ export async function POST(request: NextRequest) {
176207
);
177208
}
178209

210+
const formatCertWindow = () => {
211+
if (!certWindow.start && !certWindow.end) return 'unknown';
212+
const s = certWindow.start
213+
? certWindow.start.toISOString().slice(0, 10)
214+
: '−∞';
215+
const e = certWindow.end
216+
? certWindow.end.toISOString().slice(0, 10)
217+
: '+∞';
218+
return `${s} to ${e}`;
219+
};
220+
179221
if (contacts.length === 0) {
222+
const parts: string[] = [];
223+
if (skipped.length > 0)
224+
parts.push(`${skipped.length} unsupported prop_mode`);
225+
if (outOfRange.length > 0)
226+
parts.push(
227+
`${outOfRange.length} outside cert's QSO date range (${formatCertWindow()})`
228+
);
229+
const reason = parts.length
230+
? `No upload-eligible contacts (skipped ${parts.join(', ')})`
231+
: 'No contacts found for upload';
232+
180233
await query(
181234
`UPDATE lotw_upload_logs
182235
SET status = 'completed', completed_at = NOW(), qso_count = 0,
183236
error_message = $1
184237
WHERE id = $2`,
185-
[skipped.length > 0
186-
? `No upload-eligible contacts (skipped ${skipped.length} unsupported prop_mode QSOs)`
187-
: 'No contacts found for upload', uploadLogId]
238+
[reason, uploadLogId]
188239
);
189240

190241
const response: LotwUploadResponse = {
191242
success: true,
192243
upload_log_id: uploadLogId,
193244
qso_count: 0,
194-
error_message: 'No contacts found for upload'
245+
error_message: reason,
195246
};
196247

197248
return NextResponse.json(response);
@@ -326,26 +377,36 @@ export async function POST(request: NextRequest) {
326377
// Mark contacts as uploaded to LoTW
327378
const contactIds = contacts.map(c => c.id);
328379
await query(
329-
`UPDATE contacts
330-
SET lotw_qsl_sent = 'Y', updated_at = NOW()
380+
`UPDATE contacts
381+
SET lotw_qsl_sent = 'Y', updated_at = NOW()
331382
WHERE id = ANY($1)`,
332383
[contactIds]
333384
);
334385

386+
// Surface the out-of-range count alongside the success log so it's
387+
// visible on the /lotw upload log table — those QSOs aren't on LoTW
388+
// and the operator needs to know either to renew the cert or to fix
389+
// the QSO dates.
390+
const partialNotice = outOfRange.length
391+
? `Skipped ${outOfRange.length} QSO${outOfRange.length === 1 ? '' : 's'} outside cert's QSO date range (${formatCertWindow()})`
392+
: null;
393+
335394
// Update upload log as completed
336395
await query(
337-
`UPDATE lotw_upload_logs
338-
SET status = 'completed', completed_at = NOW(),
339-
success_count = $1, lotw_response = $2
340-
WHERE id = $3`,
341-
[contacts.length, lotwResponse, uploadLogId]
396+
`UPDATE lotw_upload_logs
397+
SET status = 'completed', completed_at = NOW(),
398+
success_count = $1, lotw_response = $2,
399+
error_message = $3
400+
WHERE id = $4`,
401+
[contacts.length, lotwResponse, partialNotice, uploadLogId]
342402
);
343403

344404
const response: LotwUploadResponse = {
345405
success: true,
346406
upload_log_id: uploadLogId,
347407
qso_count: contacts.length,
348-
lotw_response: lotwResponse
408+
lotw_response: lotwResponse,
409+
...(partialNotice ? { error_message: partialNotice } : {}),
349410
};
350411

351412
return NextResponse.json(response);

src/lib/lotw.ts

Lines changed: 96 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,10 @@ function parseLoTWDateTime(qsoDate: string, timeOn: string): Date {
263263
// =====================================================================
264264

265265
const TQSL_IDENT = 'TQSL V2.8.2 Lib: V2.6 Config: V11.34 AllowDupes: false';
266+
// ARRL's private X.509 extensions in a LoTW certificate.
267+
// Reference: https://oidref.com/1.3.6.1.4.1.12348.1
268+
const ARRL_QSO_FIRST_DATE_OID = '1.3.6.1.4.1.12348.1.2';
269+
const ARRL_QSO_END_DATE_OID = '1.3.6.1.4.1.12348.1.3';
266270
const ARRL_DXCC_OID = '1.3.6.1.4.1.12348.1.4';
267271

268272
// Internal: the parsed P12 we feed to signing.
@@ -271,8 +275,48 @@ interface ParsedP12 {
271275
certPem: string;
272276
certPemBody: string; // BEGIN/END stripped, internal newlines preserved
273277
certSerial: string; // hex, lowercased; for CRL queries
274-
certDxcc?: number; // from ARRL OID 1.3.6.1.4.1.12348.1.4
278+
certDxcc?: number; // from ARRL OID .4
275279
certNotAfter?: Date;
280+
// QSO date range encoded in ARRL OIDs .2 / .3 — LoTW silently rejects any
281+
// QSO whose date falls outside this window, even if the cert itself is
282+
// within its X.509 validity. Mirrors wavelog's preflight filter.
283+
qsoStartDate?: Date;
284+
qsoEndDate?: Date;
285+
}
286+
287+
// Read a PrintableString value out of an ARRL private extension. The forge
288+
// `value` field for unknown extensions is a binary string holding the raw
289+
// DER-encoded ASN.1 value; we parse it and pull the inner string.
290+
function readArrlPrintableExt(
291+
certBag: forge.pki.Certificate,
292+
oid: string
293+
): string | undefined {
294+
try {
295+
type ForgeExtension = { id: string; value?: unknown };
296+
const certExts =
297+
(certBag as unknown as { extensions?: ForgeExtension[] }).extensions ??
298+
[];
299+
const ext = certExts.find(e => e?.id === oid);
300+
if (!ext || typeof ext.value !== 'string') return undefined;
301+
const inner = forge.asn1.fromDer(ext.value);
302+
const innerValue = (inner as { value: unknown }).value;
303+
return typeof innerValue === 'string' ? innerValue : undefined;
304+
} catch {
305+
return undefined;
306+
}
307+
}
308+
309+
// ARRL stores the QSO-date-range bounds as compact strings — historically
310+
// "YYYYMMDD" but newer certs may use "YYYY-MM-DD". Accept both, return a
311+
// UTC midnight Date. End-of-day handling (i.e. inclusive end date) lives
312+
// at the call site, not here.
313+
function parseArrlDateString(raw: string | undefined): Date | undefined {
314+
if (!raw) return undefined;
315+
const m = raw.match(/^(\d{4})-?(\d{2})-?(\d{2})/);
316+
if (!m) return undefined;
317+
const [, y, mm, dd] = m;
318+
const d = new Date(Date.UTC(parseInt(y, 10), parseInt(mm, 10) - 1, parseInt(dd, 10)));
319+
return Number.isNaN(d.getTime()) ? undefined : d;
276320
}
277321

278322
function parseP12(buf: Buffer, password: string): ParsedP12 {
@@ -319,24 +363,21 @@ function parseP12(buf: Buffer, password: string): ParsedP12 {
319363
// forge's getExtension types accept `id: number` only, but X.509 extension
320364
// OIDs are dotted strings — search the .extensions array directly instead.
321365
let certDxcc: number | undefined;
322-
try {
323-
type ForgeExtension = { id: string; value?: unknown };
324-
const certExts = (certBag.cert as unknown as { extensions?: ForgeExtension[] }).extensions ?? [];
325-
const ext = certExts.find(e => e?.id === ARRL_DXCC_OID);
326-
if (ext && typeof ext.value === 'string') {
327-
// The extension value is DER-encoded; parse to extract the printable string.
328-
const inner = forge.asn1.fromDer(ext.value);
329-
const innerValue = (inner as { value: unknown }).value;
330-
if (typeof innerValue === 'string') {
331-
const n = parseInt(innerValue, 10);
332-
if (!Number.isNaN(n)) certDxcc = n;
333-
}
334-
}
335-
} catch {
336-
// Best-effort — if we can't read DXCC from the cert, the caller's station
337-
// profile DXCC is used. Do not fail the upload over this.
366+
const dxccRaw = readArrlPrintableExt(certBag.cert, ARRL_DXCC_OID);
367+
if (dxccRaw) {
368+
const n = parseInt(dxccRaw, 10);
369+
if (!Number.isNaN(n)) certDxcc = n;
338370
}
339371

372+
// QSO-date-range bounds from the same ARRL extension family. LoTW silently
373+
// discards any QSO whose date is outside [qsoStartDate, qsoEndDate].
374+
const qsoStartDate = parseArrlDateString(
375+
readArrlPrintableExt(certBag.cert, ARRL_QSO_FIRST_DATE_OID)
376+
);
377+
const qsoEndDate = parseArrlDateString(
378+
readArrlPrintableExt(certBag.cert, ARRL_QSO_END_DATE_OID)
379+
);
380+
340381
// Serial as hex (lowercase, no leading zeros) — matches wavelog's CRL format.
341382
const certSerial = (certBag.cert.serialNumber || '').toLowerCase();
342383

@@ -348,7 +389,16 @@ function parseP12(buf: Buffer, password: string): ParsedP12 {
348389
certNotAfter = validity.notAfter;
349390
}
350391

351-
return { privateKeyPem, certPem, certPemBody, certSerial, certDxcc, certNotAfter };
392+
return {
393+
privateKeyPem,
394+
certPem,
395+
certPemBody,
396+
certSerial,
397+
certDxcc,
398+
certNotAfter,
399+
qsoStartDate,
400+
qsoEndDate,
401+
};
352402
}
353403

354404
// Format a frequency in MHz the way TQSL does — trim trailing zeros,
@@ -624,9 +674,36 @@ export function readCertMetadata(p12: Buffer, password: string): {
624674
serial: string;
625675
notAfter?: Date;
626676
dxcc?: number;
677+
qsoStartDate?: Date;
678+
qsoEndDate?: Date;
627679
} {
628680
const parsed = parseP12(p12, password);
629-
return { serial: parsed.certSerial, notAfter: parsed.certNotAfter, dxcc: parsed.certDxcc };
681+
return {
682+
serial: parsed.certSerial,
683+
notAfter: parsed.certNotAfter,
684+
dxcc: parsed.certDxcc,
685+
qsoStartDate: parsed.qsoStartDate,
686+
qsoEndDate: parsed.qsoEndDate,
687+
};
688+
}
689+
690+
// Check whether a QSO datetime falls within the cert's allowed QSO date range.
691+
// LoTW silently discards out-of-range QSOs server-side, so we filter them
692+
// before signing and report them back to the caller. End is inclusive through
693+
// 23:59:59.999 UTC of qsoEndDate (mirroring wavelog's `qso_end_date . ' 23:59:59'`).
694+
export function isQsoWithinCertDateRange(
695+
qsoDatetime: Date,
696+
qsoStartDate: Date | undefined,
697+
qsoEndDate: Date | undefined
698+
): boolean {
699+
const t = qsoDatetime.getTime();
700+
if (qsoStartDate && t < qsoStartDate.getTime()) return false;
701+
if (qsoEndDate) {
702+
// Inclusive end-of-day in UTC.
703+
const endOfDay = qsoEndDate.getTime() + 24 * 60 * 60 * 1000 - 1;
704+
if (t > endOfDay) return false;
705+
}
706+
return true;
630707
}
631708

632709
// Generate SHA-256 hash of ADIF content for tracking

0 commit comments

Comments
 (0)