Skip to content
Merged
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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,24 @@ See `/tests/README.md` for detailed testing documentation.
3. **Log Contacts**: Add new contacts with frequency, mode, RST, and other details
4. **View Logbook**: Browse your logged contacts on the dashboard

## Scheduled sync

The cron endpoints (`/api/cron/*`) upload pending QSOs to LoTW and download
confirmations on a schedule. They require a `CRON_SECRET` environment variable
and reject every request that does not carry it — if the secret is unset the
endpoints fail closed with a 500.

- **Vercel**: set `CRON_SECRET` in the project's environment variables. Vercel
automatically attaches `Authorization: Bearer $CRON_SECRET` to the cron
invocations defined in `vercel.json`; no other setup is needed.
- **Self-hosted**: set `CRON_SECRET` in your environment and call the
endpoints from your scheduler, e.g. a crontab entry:

```cron
0 * * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-host/api/cron/lotw-upload
30 * * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-host/api/cron/lotw-download
```

## Cloudlog API Compatibility

Nextlog provides full compatibility with Cloudlog's API, allowing you to use any third-party amateur radio software that supports Cloudlog integration.
Expand Down
28 changes: 11 additions & 17 deletions src/app/api/cron/lotw-download/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { hasValidCronSecret } from '@/lib/cron-auth';

export async function GET(request: NextRequest) {
try {
Expand All @@ -18,22 +19,14 @@ export async function GET(request: NextRequest) {
}, { status: 500 });
}

// Verify this is a legitimate cron request
const authHeader = request.headers.get('authorization');
const expectedAuth = `Bearer ${process.env.CRON_SECRET}`;

// For Vercel cron jobs, we need to be more flexible with authentication
// Vercel cron jobs run in a trusted environment but may not include the auth header
const isVercelCron = request.headers.get('user-agent')?.includes('vercel') ||
request.headers.get('x-vercel-id') ||
request.headers.get('host')?.includes('vercel');

if (!isVercelCron && authHeader !== expectedAuth) {
console.error('Authentication failed:', {
hasAuthHeader: !!authHeader,
hasCronSecret: !!process.env.CRON_SECRET,
isVercelCron
});
// Verify this is a legitimate cron request. Fail closed when the secret
// is missing — Vercel only attaches the Authorization header when
// CRON_SECRET is set, and an unset secret must not mean "open endpoint".
if (!process.env.CRON_SECRET) {
console.error('CRON_SECRET is not configured; refusing cron request');
return NextResponse.json({ error: 'CRON_SECRET not configured' }, { status: 500 });
}
if (!hasValidCronSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

Expand Down Expand Up @@ -79,7 +72,8 @@ export async function GET(request: NextRequest) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Cron-Job': 'true', // Internal identifier
'X-Cron-Job': 'true', // Cron-mode discriminator (grants nothing by itself)
'Authorization': `Bearer ${process.env.CRON_SECRET}`,
},
body: JSON.stringify({
station_id: station.id,
Expand Down
28 changes: 11 additions & 17 deletions src/app/api/cron/lotw-upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { hasValidCronSecret } from '@/lib/cron-auth';

export async function GET(request: NextRequest) {
try {
Expand All @@ -18,22 +19,14 @@ export async function GET(request: NextRequest) {
}, { status: 500 });
}

// Verify this is a legitimate cron request
const authHeader = request.headers.get('authorization');
const expectedAuth = `Bearer ${process.env.CRON_SECRET}`;

// For Vercel cron jobs, we need to be more flexible with authentication
// Vercel cron jobs run in a trusted environment but may not include the auth header
const isVercelCron = request.headers.get('user-agent')?.includes('vercel') ||
request.headers.get('x-vercel-id') ||
request.headers.get('host')?.includes('vercel');

if (!isVercelCron && authHeader !== expectedAuth) {
console.error('Authentication failed:', {
hasAuthHeader: !!authHeader,
hasCronSecret: !!process.env.CRON_SECRET,
isVercelCron
});
// Verify this is a legitimate cron request. Fail closed when the secret
// is missing — Vercel only attaches the Authorization header when
// CRON_SECRET is set, and an unset secret must not mean "open endpoint".
if (!process.env.CRON_SECRET) {
console.error('CRON_SECRET is not configured; refusing cron request');
return NextResponse.json({ error: 'CRON_SECRET not configured' }, { status: 500 });
}
if (!hasValidCronSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

Expand Down Expand Up @@ -108,7 +101,8 @@ export async function GET(request: NextRequest) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Cron-Job': 'true', // Internal identifier
'X-Cron-Job': 'true', // Cron-mode discriminator (grants nothing by itself)
'Authorization': `Bearer ${process.env.CRON_SECRET}`,
},
body: JSON.stringify({
station_id: station.id,
Expand Down
11 changes: 9 additions & 2 deletions src/app/api/lotw/download/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@

import { NextRequest, NextResponse } from 'next/server';
import { verifyToken } from '@/lib/auth';
import { hasValidCronSecret } from '@/lib/cron-auth';
import { query } from '@/lib/db';
import { parseLoTWAdif, matchLoTWConfirmations, buildLoTWDownloadUrl, decryptString } from '@/lib/lotw';
import { LotwDownloadRequest, LotwDownloadResponse, ContactWithLoTW } from '@/types/lotw';

export async function POST(request: NextRequest) {
try {
// Check if this is a cron job request
const isCronJob = request.headers.get('X-Cron-Job') === 'true';
// Cron mode requires the valid CRON_SECRET Bearer token — the X-Cron-Job
// header is only a mode discriminator and grants nothing by itself
// (it is spoofable by any caller).
const cronHeaderPresent = request.headers.get('X-Cron-Job') === 'true';
const isCronJob = cronHeaderPresent && hasValidCronSecret(request);
if (cronHeaderPresent && !isCronJob) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
let user = null;

if (isCronJob) {
Expand Down
13 changes: 10 additions & 3 deletions src/app/api/lotw/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { NextRequest, NextResponse } from 'next/server';
import { verifyToken } from '@/lib/auth';
import { hasValidCronSecret } from '@/lib/cron-auth';
import { query } from '@/lib/db';
import {
buildSignedTq8,
Expand All @@ -28,10 +29,16 @@ const LOTW_UPLOAD_ACCEPTED_REGEX = /<!--\s*\.UPL\.\s*accepted\s*-->/i;

export async function POST(request: NextRequest) {
try {
// Check if this is a cron job request
const isCronJob = request.headers.get('X-Cron-Job') === 'true';
// Cron mode requires the valid CRON_SECRET Bearer token — the X-Cron-Job
// header is only a mode discriminator and grants nothing by itself
// (it is spoofable by any caller).
const cronHeaderPresent = request.headers.get('X-Cron-Job') === 'true';
const isCronJob = cronHeaderPresent && hasValidCronSecret(request);
if (cronHeaderPresent && !isCronJob) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
let user = null;

if (isCronJob) {
// For cron jobs, we'll get the user from the station_id
user = null; // Will be set later
Expand Down
15 changes: 15 additions & 0 deletions src/lib/cron-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Strict cron authentication.
//
// Vercel attaches `Authorization: Bearer ${CRON_SECRET}` to cron invocations
// automatically when the CRON_SECRET env var is set on the project.
// Self-hosted operators pass the same header from their external scheduler
// (see README "Scheduled sync"). There is no trusted-host fallback: host and
// user-agent headers are caller-controlled and must never grant auth.

import { NextRequest } from 'next/server';

export function hasValidCronSecret(request: NextRequest): boolean {
const secret = process.env.CRON_SECRET;
if (!secret) return false;
return request.headers.get('authorization') === `Bearer ${secret}`;
}
Loading