- 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>
566 lines
15 KiB
TypeScript
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>;
|
|
}
|