Spaces:
Sleeping
Sleeping
File size: 6,022 Bytes
57a889c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | import type { AirtrailFlight } from '@trek/shared';
import { db } from '../../db/database';
import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
import { checkSsrf } from '../../utils/ssrfGuard';
import { writeAudit } from '../auditLog';
import { AirtrailAuthError, AirtrailCreds, AirtrailRequestError, listFlights } from './airtrailClient';
import { normalizeFlight } from './airtrailMapper';
const KEY_MASK = '••••••••';
interface UserConnRow {
airtrail_url?: string | null;
airtrail_api_key?: string | null;
airtrail_allow_insecure_tls?: number | null;
}
function readRow(userId: number): UserConnRow | undefined {
return db
.prepare('SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls FROM users WHERE id = ?')
.get(userId) as UserConnRow | undefined;
}
/** Decrypted creds for outbound calls, or null when the user has no connection. */
export function getAirtrailCredentials(userId: number): AirtrailCreds | null {
const row = readRow(userId);
if (!row?.airtrail_url || !row?.airtrail_api_key) return null;
const apiKey = decrypt_api_key(row.airtrail_api_key);
if (!apiKey) return null;
return {
baseUrl: row.airtrail_url,
apiKey,
allowInsecureTls: !!row.airtrail_allow_insecure_tls,
};
}
/** Settings as shown in the UI — the key is never echoed, only masked. */
export function getConnectionSettings(userId: number) {
const row = readRow(userId);
return {
url: row?.airtrail_url || '',
apiKeyMasked: row?.airtrail_api_key ? KEY_MASK : '',
allowInsecureTls: !!row?.airtrail_allow_insecure_tls,
connected: !!(row?.airtrail_url && row?.airtrail_api_key),
};
}
export async function saveSettings(
userId: number,
url: string | undefined,
apiKey: string | undefined,
allowInsecureTls: boolean,
clientIp: string | null,
): Promise<{ success: boolean; warning?: string; error?: string }> {
const trimmedUrl = (url || '').trim();
let warning: string | undefined;
if (trimmedUrl) {
const ssrf = await checkSsrf(trimmedUrl);
// Reject only genuinely unusable URLs (malformed, unresolvable, non-http,
// loopback). Private/LAN instances are the common self-hosted case, so we
// persist them with a warning rather than blocking — the outbound calls
// still need ALLOW_INTERNAL_NETWORK=true to actually reach them.
if (!ssrf.allowed && !ssrf.isPrivate) {
return { success: false, error: ssrf.error ?? 'Invalid AirTrail URL' };
}
if (ssrf.isPrivate) {
writeAudit({
userId,
action: 'airtrail.private_ip_configured',
ip: clientIp,
details: { airtrail_url: trimmedUrl, resolved_ip: ssrf.resolvedIp },
});
warning = `AirTrail URL resolves to a private IP (${ssrf.resolvedIp}). Make sure this is intentional — the server may need ALLOW_INTERNAL_NETWORK=true to reach it.`;
}
}
// Only overwrite the stored key when a genuinely new value is supplied;
// a blank field or the mask means "keep the existing key".
const provided = (apiKey || '').trim();
const newKey = provided && provided !== KEY_MASK ? maybe_encrypt_api_key(provided) : undefined;
if (newKey !== undefined) {
db.prepare(
'UPDATE users SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
).run(trimmedUrl || null, newKey, allowInsecureTls ? 1 : 0, userId);
} else {
db.prepare(
'UPDATE users SET airtrail_url = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
).run(trimmedUrl || null, allowInsecureTls ? 1 : 0, userId);
// Clearing the URL with no key left makes the connection meaningless — drop the key too.
if (!trimmedUrl) {
db.prepare('UPDATE users SET airtrail_api_key = NULL WHERE id = ?').run(userId);
}
}
return { success: true, warning };
}
async function probe(creds: AirtrailCreds): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
try {
const flights = await listFlights(creds);
return { connected: true, flightCount: flights.length };
} catch (err: unknown) {
if (err instanceof AirtrailAuthError) return { connected: false, error: 'Invalid API key' };
return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
}
}
/** Live check using the stored connection. */
export async function getConnectionStatus(
userId: number,
): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
const creds = getAirtrailCredentials(userId);
if (!creds) return { connected: false, error: 'Not configured' };
return probe(creds);
}
/**
* "Test connection" from the settings form. Uses the typed URL/key when given;
* falls back to the stored key when the key field still shows the mask.
*/
export async function testConnection(
userId: number,
url: string | undefined,
apiKey: string | undefined,
allowInsecureTls: boolean,
): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
const trimmedUrl = (url || '').trim();
const provided = (apiKey || '').trim();
const stored = getAirtrailCredentials(userId);
const effectiveUrl = trimmedUrl || stored?.baseUrl;
const effectiveKey = provided && provided !== KEY_MASK ? provided : stored?.apiKey;
if (!effectiveUrl || !effectiveKey) {
return { connected: false, error: 'URL and API key required' };
}
const ssrf = await checkSsrf(effectiveUrl);
if (!ssrf.allowed && !ssrf.isPrivate) {
return { connected: false, error: ssrf.error ?? 'Invalid AirTrail URL' };
}
return probe({ baseUrl: effectiveUrl, apiKey: effectiveKey, allowInsecureTls });
}
/** The user's AirTrail flights, normalized for the import picker. */
export async function getFlightsForPicker(userId: number): Promise<AirtrailFlight[]> {
const creds = getAirtrailCredentials(userId);
if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400);
const raw = await listFlights(creds);
return raw.map(normalizeFlight);
}
|