Files
vip-coordinator/backend/src/gps/traccar-client.service.ts
kyle 3814d175ff feat: enable SSL on Traccar device port 5055
- nginx stream module now terminates SSL on port 5055
- Backend returns HTTPS URL for device server
- More secure GPS data transmission

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 23:27:35 +01:00

566 lines
15 KiB
TypeScript

import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
export interface TraccarDevice {
id: number;
name: string;
uniqueId: string;
status: string;
disabled: boolean;
lastUpdate: string | null;
positionId: number | null;
groupId: number | null;
phone: string | null;
model: string | null;
contact: string | null;
category: string | null;
attributes: Record<string, any>;
}
export interface TraccarPosition {
id: number;
deviceId: number;
protocol: string;
deviceTime: string;
fixTime: string;
serverTime: string;
outdated: boolean;
valid: boolean;
latitude: number;
longitude: number;
altitude: number;
speed: number; // knots
course: number;
address: string | null;
accuracy: number;
network: any;
attributes: {
batteryLevel?: number;
distance?: number;
totalDistance?: number;
motion?: boolean;
};
}
export interface TraccarSession {
cookie: string;
}
@Injectable()
export class TraccarClientService implements OnModuleInit {
private readonly logger = new Logger(TraccarClientService.name);
private client: AxiosInstance;
private readonly baseUrl: string;
private sessionCookie: string | null = null;
private adminUser: string = 'admin';
private adminPassword: string = 'admin';
constructor(private configService: ConfigService) {
this.baseUrl = this.configService.get<string>('TRACCAR_API_URL') || 'http://localhost:8082';
this.client = axios.create({
baseURL: this.baseUrl,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
}
async onModuleInit() {
// Don't authenticate on startup - wait for GpsService to load credentials from database
// The first request will trigger authentication with the correct credentials
this.logger.log('Traccar client initialized - waiting for credentials from database');
}
/**
* Set admin credentials (called from GpsService after loading from DB)
*/
setCredentials(username: string, password: string) {
this.adminUser = username;
this.adminPassword = password;
this.sessionCookie = null; // Force re-authentication
}
/**
* Authenticate with Traccar and get session cookie
*/
async authenticate(): Promise<boolean> {
try {
const response = await this.client.post(
'/api/session',
new URLSearchParams({
email: this.adminUser,
password: this.adminPassword,
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
// Extract session cookie
const setCookie = response.headers['set-cookie'];
if (setCookie && setCookie.length > 0) {
this.sessionCookie = setCookie[0].split(';')[0];
this.logger.log('Traccar authentication successful');
return true;
}
return false;
} catch (error: any) {
this.logger.error('Traccar authentication failed:', error.message);
throw error;
}
}
/**
* Make authenticated request to Traccar API
*/
private async request<T>(
method: 'get' | 'post' | 'put' | 'delete',
path: string,
data?: any,
): Promise<T> {
// Ensure we have a session
if (!this.sessionCookie) {
await this.authenticate();
}
try {
const response = await this.client.request({
method,
url: path,
data,
headers: {
Cookie: this.sessionCookie,
},
});
return response.data;
} catch (error: any) {
// If unauthorized, try to re-authenticate once
if (error.response?.status === 401) {
this.sessionCookie = null;
await this.authenticate();
const response = await this.client.request({
method,
url: path,
data,
headers: {
Cookie: this.sessionCookie,
},
});
return response.data;
}
throw error;
}
}
/**
* Check if Traccar is available
*/
async isAvailable(): Promise<boolean> {
try {
await this.client.get('/api/server');
return true;
} catch {
return false;
}
}
/**
* Get server info
*/
async getServerInfo(): Promise<any> {
return this.request('get', '/api/server');
}
/**
* Create a new device in Traccar
*/
async createDevice(name: string, uniqueId: string, phone?: string): Promise<TraccarDevice> {
const device = await this.request<TraccarDevice>('post', '/api/devices', {
name,
uniqueId,
phone,
category: 'person',
});
// Link device to all admin users so they can see it
await this.linkDeviceToAllAdmins(device.id);
return device;
}
/**
* Link a device to a specific user
*/
async linkDeviceToUser(deviceId: number, userId: number): Promise<boolean> {
try {
await this.request('post', '/api/permissions', {
userId,
deviceId,
});
return true;
} catch (error: any) {
// 400 means permission already exists, which is fine
if (error.response?.status === 400) {
return true;
}
this.logger.warn(`Failed to link device ${deviceId} to user ${userId}: ${error.message}`);
return false;
}
}
/**
* Link a device to all admin users
*/
async linkDeviceToAllAdmins(deviceId: number): Promise<void> {
try {
const users = await this.getAllUsers();
const admins = users.filter(u => u.administrator);
for (const admin of admins) {
await this.linkDeviceToUser(deviceId, admin.id);
}
this.logger.log(`Linked device ${deviceId} to ${admins.length} admin users`);
} catch (error: any) {
this.logger.warn(`Failed to link device to admins: ${error.message}`);
}
}
/**
* Get device by unique ID
*/
async getDeviceByUniqueId(uniqueId: string): Promise<TraccarDevice | null> {
try {
const devices = await this.request<TraccarDevice[]>('get', `/api/devices?uniqueId=${uniqueId}`);
return devices.length > 0 ? devices[0] : null;
} catch {
return null;
}
}
/**
* Get device by Traccar ID
*/
async getDevice(deviceId: number): Promise<TraccarDevice | null> {
try {
const devices = await this.request<TraccarDevice[]>('get', `/api/devices?id=${deviceId}`);
return devices.length > 0 ? devices[0] : null;
} catch {
return null;
}
}
/**
* Get all devices
*/
async getAllDevices(): Promise<TraccarDevice[]> {
return this.request('get', '/api/devices');
}
/**
* Delete a device
*/
async deleteDevice(deviceId: number): Promise<void> {
await this.request('delete', `/api/devices/${deviceId}`);
}
/**
* Get latest position for a device
*/
async getDevicePosition(deviceId: number): Promise<TraccarPosition | null> {
try {
const positions = await this.request<TraccarPosition[]>('get', `/api/positions?deviceId=${deviceId}`);
return positions.length > 0 ? positions[0] : null;
} catch {
return null;
}
}
/**
* Get all current positions
*/
async getAllPositions(): Promise<TraccarPosition[]> {
return this.request('get', '/api/positions');
}
/**
* Get position history for a device
*/
async getPositionHistory(
deviceId: number,
from: Date,
to: Date,
): Promise<TraccarPosition[]> {
const fromStr = from.toISOString();
const toStr = to.toISOString();
return this.request('get', `/api/positions?deviceId=${deviceId}&from=${fromStr}&to=${toStr}`);
}
/**
* Get trip report (includes distance traveled)
*/
async getTripReport(
deviceId: number,
from: Date,
to: Date,
): Promise<any[]> {
const fromStr = from.toISOString();
const toStr = to.toISOString();
return this.request('get', `/api/reports/trips?deviceId=${deviceId}&from=${fromStr}&to=${toStr}`);
}
/**
* Get summary report (includes total distance, max speed, etc.)
*/
async getSummaryReport(
deviceId: number,
from: Date,
to: Date,
): Promise<any[]> {
const fromStr = from.toISOString();
const toStr = to.toISOString();
return this.request('get', `/api/reports/summary?deviceId=${deviceId}&from=${fromStr}&to=${toStr}`);
}
/**
* Convert speed from knots to km/h
*/
knotsToKmh(knots: number): number {
return knots * 1.852;
}
/**
* Convert speed from knots to mph
*/
knotsToMph(knots: number): number {
return knots * 1.15078;
}
/**
* Get the device port URL for mobile app configuration
* Returns full HTTPS URL - nginx terminates SSL on port 5055
*/
getDeviceServerUrl(): string {
const port = this.configService.get<string>('TRACCAR_DEVICE_PORT') || '5055';
// Use the Traccar public URL to derive the device server hostname
const traccarUrl = this.getTraccarUrl();
try {
const url = new URL(traccarUrl);
// Return HTTPS URL - nginx terminates SSL and proxies to Traccar
return `https://${url.hostname}:${port}`;
} catch {
return `http://localhost:${port}`;
}
}
// ============================================
// User Management (for VIP Admin sync)
// ============================================
/**
* Create or update a Traccar user
*/
async createOrUpdateUser(
email: string,
name: string,
password: string,
isAdmin: boolean = false,
token?: string,
): Promise<TraccarUser> {
// Check if user exists
const existingUser = await this.getUserByEmail(email);
if (existingUser) {
// Update existing user
return this.request('put', `/api/users/${existingUser.id}`, {
...existingUser,
name,
password: password || undefined, // Only update if provided
administrator: isAdmin,
token: token || existingUser.token, // Preserve or update token
});
}
// Create new user with token for auto-login
return this.request('post', '/api/users', {
email,
name,
password,
administrator: isAdmin,
token: token || undefined,
});
}
/**
* Get or generate a token for a user (for auto-login)
*/
async ensureUserToken(email: string, token: string): Promise<string | null> {
const user = await this.getUserByEmail(email);
if (!user) return null;
// If user already has a token, return it
if (user.token) {
return user.token;
}
// Set the token on the user
const updatedUser = await this.request<TraccarUser>('put', `/api/users/${user.id}`, {
...user,
token,
});
return updatedUser.token;
}
/**
* Get user's token if they have one
*/
async getUserToken(email: string): Promise<string | null> {
const user = await this.getUserByEmail(email);
return user?.token || null;
}
/**
* Get user by email
*/
async getUserByEmail(email: string): Promise<TraccarUser | null> {
try {
const users = await this.request<TraccarUser[]>('get', '/api/users');
return users.find(u => u.email.toLowerCase() === email.toLowerCase()) || null;
} catch {
return null;
}
}
/**
* Get all users
*/
async getAllUsers(): Promise<TraccarUser[]> {
return this.request('get', '/api/users');
}
/**
* Delete a user
*/
async deleteUser(userId: number): Promise<void> {
await this.request('delete', `/api/users/${userId}`);
}
/**
* Create a session for a user (for auto-login)
* Returns session token that can be used for authentication
*/
async createUserSession(email: string, password: string): Promise<{ token: string; cookie: string } | null> {
try {
const response = await this.client.post(
'/api/session',
new URLSearchParams({
email,
password,
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
const setCookie = response.headers['set-cookie'];
if (setCookie && setCookie.length > 0) {
const cookie = setCookie[0].split(';')[0];
// Extract token if available
const token = response.data?.token || null;
return { token, cookie };
}
return null;
} catch (error) {
this.logger.error('Failed to create user session:', error);
return null;
}
}
/**
* Get Traccar base URL for frontend redirect
* Returns the public URL that users can access via the subdomain
*/
getTraccarUrl(): string {
// Check for explicit Traccar public URL first
const traccarPublicUrl = this.configService.get<string>('TRACCAR_PUBLIC_URL');
if (traccarPublicUrl) {
return traccarPublicUrl;
}
// Default: derive from frontend URL using traccar subdomain
const frontendUrl = this.configService.get<string>('FRONTEND_URL') || 'http://localhost:5173';
try {
const url = new URL(frontendUrl);
// Replace the subdomain/hostname to use traccar subdomain
// e.g., vip.madeamess.online -> traccar.vip.madeamess.online
return `${url.protocol}//traccar.${url.hostname}`;
} catch {
return 'http://localhost:8082';
}
}
/**
* Check if initial setup is needed (no users exist)
*/
async needsInitialSetup(): Promise<boolean> {
try {
// Try to access without auth - if it works, setup is needed
const response = await this.client.get('/api/server', {
validateStatus: (status) => status < 500,
});
// Traccar 6.x uses newServer=true to indicate first-time setup needed
// Also check registration=true for older versions
return response.data?.newServer === true || response.data?.registration === true;
} catch {
return true;
}
}
/**
* Perform initial setup - create first admin user
*/
async performInitialSetup(email: string, password: string): Promise<boolean> {
try {
// Register first user (becomes admin automatically)
const response = await this.client.post('/api/users', {
email,
password,
name: 'VIP Coordinator Admin',
});
if (response.status === 200) {
// Authenticate with the new credentials
this.adminUser = email;
this.adminPassword = password;
await this.authenticate();
return true;
}
return false;
} catch (error: any) {
this.logger.error('Initial setup failed:', error.message);
return false;
}
}
}
export interface TraccarUser {
id: number;
name: string;
email: string;
administrator: boolean;
disabled: boolean;
readonly: boolean;
token: string | null;
attributes: Record<string, any>;
}