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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,7 @@ VATSIM_CLIENT_SECRET=YOUR_VATSIM_CLIENT_SECRET_HERE # Get it from https://vatsim
VATSIM_REDIRECT_URI=http://localhost:9901/api/auth/vatsim/callback
VATSIM_AUTH_BASE= # https://auth.vatsim.net or https://auth-dev.vatsim.net depending on environment

PFATC_SERVER_ID=2ykygVZiX5
PFATC_PROXIES=PROXY_URL,PROXY_URL,PROXY_URL,PROXY_URL,PROXY_URL

ADMIN_IDS=1234567891011121314,123456789101112131415 # Comma separated list of Discord User IDs who should have admin access
69 changes: 69 additions & 0 deletions server/routes/pilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { getUserByUsername } from '../db/users.js';
import { mainDb } from '../db/connection.js';
import { isAdmin } from '../middleware/admin.js';
import { getControllerRatingStats } from '../db/ratings.js';
import { trafficScraper } from '../utils/trafficScraper.js';
import requireAuth from '../middleware/auth.js';
import { getAirlineData } from '../utils/getData.js';

const router = express.Router();

Expand Down Expand Up @@ -101,4 +104,70 @@ router.get('/:username', async (req, res) => {
}
});

// GET: /api/pilot/callsign - Get current callsign for logged in user
router.get('/callsign/data', requireAuth, async (req, res) => {
try {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}

const user = await mainDb
.selectFrom('users')
.select(['roblox_username'])
.where('id', '=', req.user.userId)
.executeTakeFirst();

if (!user || !user.roblox_username) {
return res.status(404).json({ error: 'Roblox account not connected' });
}

const trafficData = trafficScraper.getCallsignForUser(user.roblox_username);

if (!trafficData) {
return res.status(404).json({ error: 'No active flight found for this user' });
}

let airlineName = null;
let convertedCallsign = trafficData.callsign;

if (trafficData.callsign) {
const airlineData = getAirlineData();
interface Airline {
icao: string;
callsign: string;
}

const sortedAirlines = [...airlineData].sort((a, b) => b.callsign.length - a.callsign.length);

const upperCallsign = trafficData.callsign.toUpperCase();
const match = sortedAirlines.find((a: Airline) => upperCallsign.startsWith(a.callsign.toUpperCase()));

if (match) {
airlineName = match.callsign;
const remaining = upperCallsign.substring(match.callsign.length).trim();
convertedCallsign = `${match.icao}${remaining}`;
} else {
const icaoMatch = trafficData.callsign.match(/^([A-Z]{3})/);
if (icaoMatch) {
const icao = icaoMatch[1];
const airline = airlineData.find((a: Airline) => a.icao === icao);
if (airline) {
airlineName = airline.callsign;
}
}
}
}

res.json({
...trafficData,
callsign: convertedCallsign,
originalCallsign: trafficData.callsign,
airlineName,
});
} catch (error) {
console.error('Error fetching pilot callsign:', error);
res.status(500).json({ error: 'Failed to fetch pilot callsign' });
}
});

export default router;
8 changes: 8 additions & 0 deletions server/utils/getData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const __dirname = path.dirname(__filename);
const airportsPath = path.join(__dirname, '..', 'data', 'airportData.json');
const aircraftPath = path.join(__dirname, '..', 'data', 'aircraftData.json');
const waypointsPath = path.join(__dirname, '..', 'data', 'waypointData.json');
const airlinesPath = path.join(__dirname, '..', 'data', 'airlineData.json');

export function getAirportData() {
if (!fs.existsSync(airportsPath)) {
Expand All @@ -28,4 +29,11 @@ export function getWaypointData() {
throw new Error('Waypoint data not found');
}
return JSON.parse(fs.readFileSync(waypointsPath, 'utf8'));
}

export function getAirlineData() {
if (!fs.existsSync(airlinesPath)) {
throw new Error('Airline data not found');
}
return JSON.parse(fs.readFileSync(airlinesPath, 'utf8'));
}
169 changes: 169 additions & 0 deletions server/utils/trafficScraper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import WebSocket from 'ws';
import protobuf from 'protobufjs';
import { HttpsProxyAgent } from 'https-proxy-agent';
import dotenv from 'dotenv';
import type { Agent } from 'http';

dotenv.config();

const root = new protobuf.Root();
const data = root.define('data');

data.add(
new protobuf.Type('Timestamp').add(
new protobuf.Field('timestamp', 1, 'uint64')
)
);

data.add(
new protobuf.Type('Waypoint')
.add(new protobuf.Field('x', 1, 'double'))
.add(new protobuf.Field('y', 2, 'double'))
.add(new protobuf.Field('airport_code', 3, 'string'))
.add(new protobuf.Field('runway', 4, 'string'))
.add(new protobuf.Field('distance_or_bearing', 5, 'double'))
.add(new protobuf.Field('time', 6, 'Timestamp'))
);

data.add(
new protobuf.Type('Plane')
.add(new protobuf.Field('server_id', 1, 'string'))
.add(new protobuf.Field('callsign', 2, 'string'))
.add(new protobuf.Field('roblox_username', 3, 'string'))
.add(new protobuf.Field('x', 4, 'double'))
.add(new protobuf.Field('y', 5, 'double'))
.add(new protobuf.Field('heading', 6, 'double'))
.add(new protobuf.Field('altitude', 7, 'double'))
.add(new protobuf.Field('speed', 8, 'double'))
.add(new protobuf.Field('model', 9, 'string'))
.add(new protobuf.Field('livery', 10, 'string'))
);

data.add(
new protobuf.Type('planes')
.add(new protobuf.Field('planes', 1, 'Plane', 'repeated'))
.add(new protobuf.Field('waypoints', 2, 'Waypoint', 'repeated'))
);

const planesType = root.lookupType('data.planes');

interface PlaneData {
callsign: string;
robloxUsername: string;
model: string;
}

class TrafficScraper {
private static instance: TrafficScraper;
private ws: WebSocket | null = null;
private traffic: Map<string, PlaneData> = new Map();
private reconnectInterval: NodeJS.Timeout | null = null;
private currentProxyIndex: number = 0;

private constructor() {
this.connect();
}

public static getInstance(): TrafficScraper {
if (!TrafficScraper.instance) {
TrafficScraper.instance = new TrafficScraper();
}
return TrafficScraper.instance;
}

private getProxyList(): string[] {
const envProxies = process.env.PFATC_PROXIES;
if (envProxies) {
return envProxies.split(',').map((p) => p.trim()).filter(Boolean);
}
const singleProxy = process.env.PFATC_PROXY_URL;
return singleProxy ? [singleProxy] : [];
}

private connect() {
const proxies = this.getProxyList();
const proxyUrl = proxies[this.currentProxyIndex % proxies.length];
const SERVER_ID = process.env.PFATC_SERVER_ID || '2ykygVZiX5';
const WS_URL = `wss://v3api.project-flight.com/v3/traffic/server/ws/${SERVER_ID}`;

const wsOptions: WebSocket.ClientOptions = {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Origin: 'https://scope.project-flight.com',
},
};

if (proxyUrl) {
wsOptions.agent = new HttpsProxyAgent(proxyUrl) as unknown as Agent;
}

this.ws = new WebSocket(WS_URL, wsOptions);

this.ws.on('open', () => {
if (this.reconnectInterval) {
clearInterval(this.reconnectInterval);
this.reconnectInterval = null;
}
});

this.ws.on('message', async (messageData: WebSocket.Data) => {
try {
let buffer: Uint8Array;
if (messageData instanceof Buffer) {
buffer = new Uint8Array(messageData);
} else if (Array.isArray(messageData)) {
buffer = new Uint8Array(Buffer.concat(messageData));
} else if (messageData instanceof ArrayBuffer) {
buffer = new Uint8Array(messageData);
} else {
buffer = new TextEncoder().encode(messageData.toString());
}

const decoded = planesType.decode(buffer);
const object = planesType.toObject(decoded, {
defaults: true,
longs: String,
enums: String,
bytes: String,
}) as { planes?: Array<{ roblox_username?: string; callsign?: string; model?: string }> };

if (object.planes && Array.isArray(object.planes)) {
object.planes.forEach((plane) => {
if (plane.roblox_username) {
this.traffic.set(plane.roblox_username.toLowerCase(), {
callsign: plane.callsign || '',
robloxUsername: plane.roblox_username,
model: plane.model || '',
});
}
});
}
} catch (err) {
console.error('[TrafficScraper] Failed to decode protobuf message:', err);
}
});

this.ws.on('close', () => {
this.currentProxyIndex++;
this.scheduleReconnect();
});

this.ws.on('error', (err) => {
console.error(`[TrafficScraper] WebSocket error with proxy index ${this.currentProxyIndex % proxies.length}:`, err.message);
this.ws?.terminate();
});
}

private scheduleReconnect() {
if (!this.reconnectInterval) {
this.reconnectInterval = setInterval(() => this.connect(), 10000);
}
}

public getCallsignForUser(robloxUsername: string): PlaneData | null {
return this.traffic.get(robloxUsername.toLowerCase()) || null;
}
}

export const trafficScraper = TrafficScraper.getInstance();
40 changes: 17 additions & 23 deletions src/components/common/CallsignInput.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import React, { useState, useRef, useEffect } from 'react';
import { useData } from '../../hooks/data/useData';
import { ChevronDown } from 'lucide-react';
import PrefilledIndicator from './PrefilledIndicator';

interface CallsignInputProps {
value: string;
onChange: (value: string) => void;
onChange: (_newValue: string) => void;
placeholder?: string;
required?: boolean;
maxLength?: number;
isPrefilled?: boolean;
}

export default function CallsignInput({
Expand All @@ -16,19 +18,18 @@ export default function CallsignInput({
placeholder = 'e.g. DLH123',
required = false,
maxLength = 16,
isPrefilled = false,
}: CallsignInputProps) {
const { airlines } = useData();
const [showSuggestions, setShowSuggestions] = useState(false);
const [filteredAirlines, setFilteredAirlines] = useState(airlines);
const [dropdownSearch, setDropdownSearch] = useState('');
const [validationError, setValidationError] = useState<string>('');
const [hasBlurred, setHasBlurred] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const mainSearchTerm = value.toUpperCase();
const dropdownSearchTerm = dropdownSearch.toUpperCase();

let filtered = airlines;

Expand All @@ -41,14 +42,6 @@ export default function CallsignInput({
);
}

if (dropdownSearch) {
filtered = filtered.filter(
(airline) =>
airline.icao.toUpperCase().includes(dropdownSearchTerm) ||
airline.callsign.toUpperCase().includes(dropdownSearchTerm)
);
}

filtered.sort((a, b) => {
const aIcaoMatch = a.icao.toUpperCase().startsWith(mainSearchTerm);
const bIcaoMatch = b.icao.toUpperCase().startsWith(mainSearchTerm);
Expand All @@ -67,7 +60,7 @@ export default function CallsignInput({
});

setFilteredAirlines(filtered);
}, [value, dropdownSearch, airlines]);
}, [value, airlines]);

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
Expand Down Expand Up @@ -127,13 +120,11 @@ export default function CallsignInput({
return (
<div className="relative">
<div
className={`relative bg-gray-800 border-2 transition-all duration-75 z-10 ${
shouldShowError ? 'border-red-600' : 'border-blue-600'
} ${
showSuggestions && filteredAirlines.length > 0
className={`relative bg-gray-800 border-2 transition-all duration-75 z-10 ${shouldShowError ? 'border-red-600' : 'border-blue-600'
} ${showSuggestions && filteredAirlines.length > 0
? 'rounded-t-3xl rounded-b-none border-b-0'
: 'rounded-3xl'
}`}
}`}
>
<input
ref={inputRef}
Expand All @@ -144,14 +135,17 @@ export default function CallsignInput({
onBlur={handleBlur}
required={required}
placeholder={placeholder}
className="w-full pl-6 pr-10 p-3 bg-transparent text-white font-semibold focus:outline-none"
className={`w-full pl-6 p-3 bg-transparent text-white font-semibold focus:outline-none ${isPrefilled || (filteredAirlines.length > 0) ? 'pr-14' : 'pr-6'}`}
maxLength={maxLength}
/>
{filteredAirlines.length > 0 && (
<ChevronDown
className={`absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 transition-transform duration-300 ${showSuggestions ? 'rotate-180' : ''}`}
/>
)}
<div className="absolute right-6 top-1/2 -translate-y-1/2 flex items-center gap-2">
{isPrefilled && <PrefilledIndicator />}
{filteredAirlines.length > 0 && (
<ChevronDown
className={`h-5 w-5 text-gray-400 transition-transform duration-300 ${showSuggestions ? 'rotate-180' : ''}`}
/>
)}
</div>
</div>

{shouldShowError && (
Expand Down
Loading