From 34b31df73c9618f13feb0b1405d2d63ef8ae0888 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Mon, 11 May 2026 14:33:16 -0500 Subject: [PATCH] fix(api): make wavelog auth/station_info accept GridTracker's request shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GridTracker's Cloudlog client (inspected via app.asar src) sends requests that don't quite match what nextlog implemented: 1. POST {URL}/index.php/api/auth/{KEY} (not GET) 2. POST {URL}/index.php/api/station_info/{KEY} (key in path, no body) Our /api/auth/[key] only exported GET → Vercel returned 405 Method Not Allowed with an empty body → GT logged "Invalid Response" and stalled at "Testing API Key". Our /api/station_info only accepted the key in the JSON body, so /api/station_info/{KEY} 404'd → after auth's "OK" the profile-fill response was empty → GT overwrote "OK" with the same "Invalid Response" message. This commit: - /api/auth/[key]: alias GET and POST to one handler; add an OPTIONS handler for CORS preflight. Matches wavelog/CodeIgniter behavior where controllers respond to any HTTP method. - /api/station_info/[key]: new dynamic route, POST + GET both supported - /api/station_info (body form) preserved as-is, no breaking change - Extract shared lookup logic into src/lib/station-info.ts so both routes return identical wavelog-shape JSON Verified GT's full flow by reading C:\Users\burns\AppData\Local\Programs\GridTracker2\resources\app.asar src/renderer/lib/adif.js — functions CloudlogTest, CloudlogGetProfiles, CloudlogTestApiKey, CloudlogFillProfiles. After this lands, GT's Test button should show "OK" and populate the station profile dropdown. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/auth/[key]/route.ts | 24 +++++++++++-- src/app/api/station_info/[key]/route.ts | 22 ++++++++++++ src/app/api/station_info/route.ts | 45 +++---------------------- src/lib/station-info.ts | 39 +++++++++++++++++++++ 4 files changed, 88 insertions(+), 42 deletions(-) create mode 100644 src/app/api/station_info/[key]/route.ts create mode 100644 src/lib/station-info.ts diff --git a/src/app/api/auth/[key]/route.ts b/src/app/api/auth/[key]/route.ts index 7efdbd5..1113c37 100644 --- a/src/app/api/auth/[key]/route.ts +++ b/src/app/api/auth/[key]/route.ts @@ -1,7 +1,12 @@ -// GET /api/auth/ — wavelog wire-compatible API key validation. +// /api/auth/ — wavelog wire-compatible API key validation. // Returns XML, matching wavelog's ... shape. The HTTP status is // 200 regardless of whether the key is valid (matching wavelog's quirk — // clients distinguish via the body, not the status). +// +// Accepts both GET and POST: wavelog runs on CodeIgniter, whose controllers +// answer any HTTP method by default, and several clients (GridTracker, in +// particular) POST to this URL even though the key is in the path. Honor +// both verbs so those clients work. import { NextRequest } from 'next/server'; import { verifyApiKeyValue } from '@/lib/api-auth'; @@ -22,7 +27,7 @@ function invalidKeyResponse() { return xmlResponse('\n Key Invalid - either not found or disabled\n'); } -export async function GET( +async function handle( _request: NextRequest, { params }: { params: Promise<{ key: string }> }, ) { @@ -35,3 +40,18 @@ export async function GET( const rights = authResult.auth.isReadOnly ? 'r' : 'rw'; return xmlResponse(`\n Valid\n ${rights}\n`); } + +export const GET = handle; +export const POST = handle; + +export function OPTIONS() { + return new Response(null, { + status: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Key', + 'Access-Control-Max-Age': '86400', + }, + }); +} diff --git a/src/app/api/station_info/[key]/route.ts b/src/app/api/station_info/[key]/route.ts new file mode 100644 index 0000000..13e72ef --- /dev/null +++ b/src/app/api/station_info/[key]/route.ts @@ -0,0 +1,22 @@ +// POST /api/station_info/ — wavelog wire-compatible variant where the +// API key is passed as a path segment. GridTracker uses this form when +// fetching the station profile list after a successful auth. + +import { NextRequest } from 'next/server'; +import { stationInfoResponse } from '@/lib/station-info'; +import { createCorsPreflightResponse } from '@/lib/cors'; + +export async function OPTIONS() { + return createCorsPreflightResponse(); +} + +async function handle( + _request: NextRequest, + { params }: { params: Promise<{ key: string }> }, +) { + const { key } = await params; + return stationInfoResponse(key); +} + +export const GET = handle; +export const POST = handle; diff --git a/src/app/api/station_info/route.ts b/src/app/api/station_info/route.ts index f657249..ed44b92 100644 --- a/src/app/api/station_info/route.ts +++ b/src/app/api/station_info/route.ts @@ -1,13 +1,10 @@ // POST /api/station_info — wavelog wire-compatible station profile listing. // Body: { "key": "nextlog_..." } -// Returns 200 with an array of station profiles in wavelog's shape. Respects -// per-key station scoping (api_keys.station_id): a key scoped to a single -// station only sees that station. +// See /api/station_info/[key] for GridTracker's path-segment-auth variant. -import { NextRequest, NextResponse } from 'next/server'; -import { verifyApiKeyValue } from '@/lib/api-auth'; -import { addCorsHeaders, createCorsPreflightResponse } from '@/lib/cors'; -import { query } from '@/lib/db'; +import { NextRequest } from 'next/server'; +import { stationInfoResponse } from '@/lib/station-info'; +import { createCorsPreflightResponse } from '@/lib/cors'; export async function OPTIONS() { return createCorsPreflightResponse(); @@ -21,37 +18,5 @@ export async function POST(request: NextRequest) { } catch { // fall through to auth failure } - - const authResult = await verifyApiKeyValue(key); - if (!authResult.success || !authResult.auth) { - return addCorsHeaders( - NextResponse.json( - { status: 'failed', reason: 'missing or invalid api key' }, - { status: 401 }, - ), - ); - } - const auth = authResult.auth; - - const sql = auth.stationId !== null - ? 'SELECT id, station_name, grid_locator, callsign, is_active FROM stations WHERE user_id = $1 AND id = $2 ORDER BY id' - : 'SELECT id, station_name, grid_locator, callsign, is_active FROM stations WHERE user_id = $1 ORDER BY id'; - const params = auth.stationId !== null ? [auth.userId, auth.stationId] : [auth.userId]; - - const result = await query(sql, params); - - // Wavelog returns a plain JSON array (not wrapped in {success,...}), with - // station_active as 1/0 (not boolean) and a station_uuid string. Nextlog - // doesn't have a uuid column — return the id as a string so clients that - // key off it still get a stable value. - const stations = result.rows.map((row: Record) => ({ - station_id: row.id, - station_profile_name: row.station_name, - station_gridsquare: row.grid_locator ?? '', - station_callsign: row.callsign, - station_active: row.is_active ? 1 : 0, - station_uuid: String(row.id), - })); - - return addCorsHeaders(NextResponse.json(stations, { status: 200 })); + return stationInfoResponse(key); } diff --git a/src/lib/station-info.ts b/src/lib/station-info.ts new file mode 100644 index 0000000..abc09d7 --- /dev/null +++ b/src/lib/station-info.ts @@ -0,0 +1,39 @@ +// Shared response builder for wavelog-compatible station_info lookups. +// Used by both /api/station_info (key in JSON body) and +// /api/station_info/[key] (key as path segment — GridTracker's form). + +import { NextResponse } from 'next/server'; +import { verifyApiKeyValue } from '@/lib/api-auth'; +import { addCorsHeaders } from '@/lib/cors'; +import { query } from '@/lib/db'; + +export async function stationInfoResponse(key: string) { + const authResult = await verifyApiKeyValue(key); + if (!authResult.success || !authResult.auth) { + return addCorsHeaders( + NextResponse.json( + { status: 'failed', reason: 'missing or invalid api key' }, + { status: 401 }, + ), + ); + } + const auth = authResult.auth; + + const sql = auth.stationId !== null + ? 'SELECT id, station_name, grid_locator, callsign, is_active FROM stations WHERE user_id = $1 AND id = $2 ORDER BY id' + : 'SELECT id, station_name, grid_locator, callsign, is_active FROM stations WHERE user_id = $1 ORDER BY id'; + const params = auth.stationId !== null ? [auth.userId, auth.stationId] : [auth.userId]; + + const result = await query(sql, params); + + const stations = result.rows.map((row: Record) => ({ + station_id: row.id, + station_profile_name: row.station_name, + station_gridsquare: row.grid_locator ?? '', + station_callsign: row.callsign, + station_active: row.is_active ? 1 : 0, + station_uuid: String(row.id), + })); + + return addCorsHeaders(NextResponse.json(stations, { status: 200 })); +}