Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion src/app/api/lotw/upload-contact/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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,
Expand Down
91 changes: 76 additions & 15 deletions src/app/api/lotw/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
generateAdifHash,
normalizeCallsign,
decryptString,
readCertMetadata,
isQsoWithinCertDateRange,
} from '@/lib/lotw';
import {
LotwUploadRequest,
Expand Down Expand Up @@ -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<typeof readCertMetadata> | 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(
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
115 changes: 96 additions & 19 deletions src/lib/lotw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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();

Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading