| 1 |
/** |
| 2 |
* Desktop Integration API routes for ZephyrFS |
| 3 |
* |
| 4 |
* Handles communication between the desktop app and web interface |
| 5 |
* for seamless authentication and configuration synchronization. |
| 6 |
*/ |
| 7 |
|
| 8 |
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; |
| 9 |
import { z } from 'zod'; |
| 10 |
import { db } from '../database/db.js'; |
| 11 |
import jwt from 'jsonwebtoken'; |
| 12 |
import { v4 as uuidv4 } from 'uuid'; |
| 13 |
import bcrypt from 'bcrypt'; |
| 14 |
|
| 15 |
// Validation schemas |
| 16 |
const DesktopTokenSchema = z.object({ |
| 17 |
desktop_token: z.string().uuid(), |
| 18 |
desktop_metadata: z.object({ |
| 19 |
app_version: z.string(), |
| 20 |
os: z.string(), |
| 21 |
machine_id: z.string(), |
| 22 |
storage_config: z.object({ |
| 23 |
storage_folder: z.string(), |
| 24 |
storage_limit: z.number(), |
| 25 |
current_usage: z.number(), |
| 26 |
node_status: z.enum(['Inactive', 'Starting', 'Running', 'Paused', 'Error']), |
| 27 |
}).optional(), |
| 28 |
}), |
| 29 |
}); |
| 30 |
|
| 31 |
const RegistrationFlowSchema = z.object({ |
| 32 |
desktop_token: z.string().uuid(), |
| 33 |
preferred_user_type: z.enum(['Backup', 'Volunteer']).optional(), |
| 34 |
return_url: z.string().url(), |
| 35 |
storage_ready: z.boolean(), |
| 36 |
capabilities: z.array(z.string()), |
| 37 |
}); |
| 38 |
|
| 39 |
const ConfigSyncRequestSchema = z.object({ |
| 40 |
desktop_token: z.string().uuid(), |
| 41 |
desktop_config: z.record(z.any()), |
| 42 |
last_sync: z.number(), |
| 43 |
}); |
| 44 |
|
| 45 |
interface DesktopSession { |
| 46 |
id: string; |
| 47 |
desktop_token: string; |
| 48 |
user_id: string | null; |
| 49 |
user_type: 'backup' | 'volunteer' | null; |
| 50 |
machine_id: string; |
| 51 |
app_version: string; |
| 52 |
os: string; |
| 53 |
created_at: Date; |
| 54 |
expires_at: Date; |
| 55 |
storage_config: any | null; |
| 56 |
} |
| 57 |
|
| 58 |
/** |
| 59 |
* Register desktop integration routes |
| 60 |
*/ |
| 61 |
export async function desktopIntegrationRoutes(fastify: FastifyInstance) { |
| 62 |
// Middleware to validate desktop token |
| 63 |
const validateDesktopToken = async (request: FastifyRequest, reply: FastifyReply) => { |
| 64 |
const authHeader = request.headers.authorization; |
| 65 |
if (!authHeader || !authHeader.startsWith('Bearer ')) { |
| 66 |
return reply.code(401).send({ error: 'Desktop token required' }); |
| 67 |
} |
| 68 |
|
| 69 |
const token = authHeader.substring(7); |
| 70 |
try { |
| 71 |
const session = await getDesktopSession(token); |
| 72 |
if (!session) { |
| 73 |
return reply.code(401).send({ error: 'Invalid desktop token' }); |
| 74 |
} |
| 75 |
|
| 76 |
if (new Date() > session.expires_at) { |
| 77 |
await deleteDesktopSession(token); |
| 78 |
return reply.code(401).send({ error: 'Desktop token expired' }); |
| 79 |
} |
| 80 |
|
| 81 |
(request as any).desktopSession = session; |
| 82 |
} catch (error) { |
| 83 |
fastify.log.error('Desktop token validation error:', error); |
| 84 |
return reply.code(500).send({ error: 'Token validation failed' }); |
| 85 |
} |
| 86 |
}; |
| 87 |
|
| 88 |
// Register desktop app and create session |
| 89 |
fastify.post('/api/desktop/register', async (request, reply) => { |
| 90 |
try { |
| 91 |
const validatedData = DesktopTokenSchema.parse(request.body); |
| 92 |
|
| 93 |
const sessionId = uuidv4(); |
| 94 |
const expiresAt = new Date(); |
| 95 |
expiresAt.setDate(expiresAt.getDate() + 7); // 7 days |
| 96 |
|
| 97 |
const session: DesktopSession = { |
| 98 |
id: sessionId, |
| 99 |
desktop_token: validatedData.desktop_token, |
| 100 |
user_id: null, |
| 101 |
user_type: null, |
| 102 |
machine_id: validatedData.desktop_metadata.machine_id, |
| 103 |
app_version: validatedData.desktop_metadata.app_version, |
| 104 |
os: validatedData.desktop_metadata.os, |
| 105 |
created_at: new Date(), |
| 106 |
expires_at: expiresAt, |
| 107 |
storage_config: validatedData.desktop_metadata.storage_config || null, |
| 108 |
}; |
| 109 |
|
| 110 |
await storeDesktopSession(session); |
| 111 |
|
| 112 |
return reply.send({ |
| 113 |
success: true, |
| 114 |
session_id: sessionId, |
| 115 |
expires_at: expiresAt.toISOString(), |
| 116 |
message: 'Desktop session created successfully', |
| 117 |
}); |
| 118 |
} catch (error) { |
| 119 |
fastify.log.error('Desktop registration error:', error); |
| 120 |
return reply.code(400).send({ error: 'Invalid registration data' }); |
| 121 |
} |
| 122 |
}); |
| 123 |
|
| 124 |
// Handle registration flow from desktop app |
| 125 |
fastify.post('/api/desktop/registration-flow', async (request, reply) => { |
| 126 |
try { |
| 127 |
const validatedData = RegistrationFlowSchema.parse(request.body); |
| 128 |
|
| 129 |
// Store registration flow data for web interface |
| 130 |
await storeRegistrationFlow(validatedData); |
| 131 |
|
| 132 |
// Generate a temporary registration token |
| 133 |
const registrationToken = jwt.sign( |
| 134 |
{ |
| 135 |
desktop_token: validatedData.desktop_token, |
| 136 |
flow_type: 'registration', |
| 137 |
preferred_user_type: validatedData.preferred_user_type, |
| 138 |
}, |
| 139 |
process.env.JWT_SECRET!, |
| 140 |
{ expiresIn: '1h' } |
| 141 |
); |
| 142 |
|
| 143 |
return reply.send({ |
| 144 |
success: true, |
| 145 |
registration_token: registrationToken, |
| 146 |
return_url: validatedData.return_url, |
| 147 |
storage_ready: validatedData.storage_ready, |
| 148 |
}); |
| 149 |
} catch (error) { |
| 150 |
fastify.log.error('Registration flow error:', error); |
| 151 |
return reply.code(400).send({ error: 'Invalid registration flow data' }); |
| 152 |
} |
| 153 |
}); |
| 154 |
|
| 155 |
// Get authentication status for desktop app |
| 156 |
fastify.get('/api/desktop/auth-status', { |
| 157 |
preHandler: validateDesktopToken, |
| 158 |
}, async (request, reply) => { |
| 159 |
const session = (request as any).desktopSession as DesktopSession; |
| 160 |
|
| 161 |
if (session.user_id) { |
| 162 |
// Get user details |
| 163 |
const user = await db |
| 164 |
.selectFrom('users') |
| 165 |
.select(['id', 'email', 'display_name', 'user_type']) |
| 166 |
.where('id', '=', session.user_id) |
| 167 |
.executeTakeFirst(); |
| 168 |
|
| 169 |
if (user) { |
| 170 |
return reply.send({ |
| 171 |
authenticated: true, |
| 172 |
user_id: user.id, |
| 173 |
user_type: user.user_type === 'backup' ? 'Backup' : 'Volunteer', |
| 174 |
display_name: user.display_name || user.email, |
| 175 |
email: user.email, |
| 176 |
}); |
| 177 |
} |
| 178 |
} |
| 179 |
|
| 180 |
return reply.send({ |
| 181 |
authenticated: false, |
| 182 |
user_id: null, |
| 183 |
user_type: null, |
| 184 |
display_name: null, |
| 185 |
email: null, |
| 186 |
}); |
| 187 |
}); |
| 188 |
|
| 189 |
// Link web authentication to desktop session |
| 190 |
fastify.post('/api/desktop/link-auth', async (request, reply) => { |
| 191 |
try { |
| 192 |
const { desktop_token, web_session_token } = request.body as { |
| 193 |
desktop_token: string; |
| 194 |
web_session_token: string; |
| 195 |
}; |
| 196 |
|
| 197 |
// Verify web session token |
| 198 |
const webSession = jwt.verify(web_session_token, process.env.JWT_SECRET!) as any; |
| 199 |
|
| 200 |
if (!webSession.userId) { |
| 201 |
return reply.code(400).send({ error: 'Invalid web session' }); |
| 202 |
} |
| 203 |
|
| 204 |
// Update desktop session with user authentication |
| 205 |
const updated = await linkDesktopSessionToUser( |
| 206 |
desktop_token, |
| 207 |
webSession.userId, |
| 208 |
webSession.userType |
| 209 |
); |
| 210 |
|
| 211 |
if (!updated) { |
| 212 |
return reply.code(404).send({ error: 'Desktop session not found' }); |
| 213 |
} |
| 214 |
|
| 215 |
return reply.send({ |
| 216 |
success: true, |
| 217 |
message: 'Desktop session linked to user account', |
| 218 |
user_id: webSession.userId, |
| 219 |
user_type: webSession.userType, |
| 220 |
}); |
| 221 |
} catch (error) { |
| 222 |
fastify.log.error('Auth linking error:', error); |
| 223 |
return reply.code(500).send({ error: 'Failed to link authentication' }); |
| 224 |
} |
| 225 |
}); |
| 226 |
|
| 227 |
// Sync configuration between desktop and web |
| 228 |
fastify.post('/api/desktop/sync', { |
| 229 |
preHandler: validateDesktopToken, |
| 230 |
}, async (request, reply) => { |
| 231 |
try { |
| 232 |
const validatedData = ConfigSyncRequestSchema.parse(request.body); |
| 233 |
const session = (request as any).desktopSession as DesktopSession; |
| 234 |
|
| 235 |
// Update desktop configuration in session |
| 236 |
await updateDesktopConfig(session.desktop_token, validatedData.desktop_config); |
| 237 |
|
| 238 |
// Get any configuration updates from web interface |
| 239 |
const webConfig = await getWebConfigUpdates(session.user_id, validatedData.last_sync); |
| 240 |
|
| 241 |
return reply.send({ |
| 242 |
success: true, |
| 243 |
changes_applied: Object.keys(webConfig).length, |
| 244 |
web_config: webConfig, |
| 245 |
next_sync: Math.floor(Date.now() / 1000) + 300, // 5 minutes from now |
| 246 |
}); |
| 247 |
} catch (error) { |
| 248 |
fastify.log.error('Config sync error:', error); |
| 249 |
return reply.code(500).send({ error: 'Configuration sync failed' }); |
| 250 |
} |
| 251 |
}); |
| 252 |
|
| 253 |
// Handle desktop app logout |
| 254 |
fastify.post('/api/desktop/logout', { |
| 255 |
preHandler: validateDesktopToken, |
| 256 |
}, async (request, reply) => { |
| 257 |
const session = (request as any).desktopSession as DesktopSession; |
| 258 |
|
| 259 |
// Remove user association but keep desktop session for re-auth |
| 260 |
await unlinkDesktopSessionFromUser(session.desktop_token); |
| 261 |
|
| 262 |
return reply.send({ |
| 263 |
success: true, |
| 264 |
message: 'Desktop session unlinked from user account', |
| 265 |
}); |
| 266 |
}); |
| 267 |
|
| 268 |
// Get desktop sessions for debugging (admin only) |
| 269 |
fastify.get('/api/admin/desktop-sessions', { |
| 270 |
preHandler: async (request, reply) => { |
| 271 |
// Add admin authentication check here |
| 272 |
// For now, just a simple check |
| 273 |
const adminToken = request.headers['x-admin-token']; |
| 274 |
if (adminToken !== process.env.ADMIN_TOKEN) { |
| 275 |
return reply.code(403).send({ error: 'Admin access required' }); |
| 276 |
} |
| 277 |
}, |
| 278 |
}, async (request, reply) => { |
| 279 |
const sessions = await getAllDesktopSessions(); |
| 280 |
return reply.send({ sessions }); |
| 281 |
}); |
| 282 |
} |
| 283 |
|
| 284 |
// Database operations for desktop sessions |
| 285 |
async function storeDesktopSession(session: DesktopSession): Promise<void> { |
| 286 |
await db |
| 287 |
.insertInto('desktop_sessions') |
| 288 |
.values({ |
| 289 |
id: session.id, |
| 290 |
desktop_token: session.desktop_token, |
| 291 |
user_id: session.user_id, |
| 292 |
user_type: session.user_type, |
| 293 |
machine_id: session.machine_id, |
| 294 |
app_version: session.app_version, |
| 295 |
os: session.os, |
| 296 |
created_at: session.created_at, |
| 297 |
expires_at: session.expires_at, |
| 298 |
storage_config: JSON.stringify(session.storage_config), |
| 299 |
}) |
| 300 |
.execute(); |
| 301 |
} |
| 302 |
|
| 303 |
async function getDesktopSession(token: string): Promise<DesktopSession | null> { |
| 304 |
const result = await db |
| 305 |
.selectFrom('desktop_sessions') |
| 306 |
.selectAll() |
| 307 |
.where('desktop_token', '=', token) |
| 308 |
.executeTakeFirst(); |
| 309 |
|
| 310 |
if (!result) return null; |
| 311 |
|
| 312 |
return { |
| 313 |
id: result.id, |
| 314 |
desktop_token: result.desktop_token, |
| 315 |
user_id: result.user_id, |
| 316 |
user_type: result.user_type as 'backup' | 'volunteer' | null, |
| 317 |
machine_id: result.machine_id, |
| 318 |
app_version: result.app_version, |
| 319 |
os: result.os, |
| 320 |
created_at: result.created_at, |
| 321 |
expires_at: result.expires_at, |
| 322 |
storage_config: result.storage_config ? JSON.parse(result.storage_config) : null, |
| 323 |
}; |
| 324 |
} |
| 325 |
|
| 326 |
async function linkDesktopSessionToUser( |
| 327 |
desktopToken: string, |
| 328 |
userId: string, |
| 329 |
userType: string |
| 330 |
): Promise<boolean> { |
| 331 |
const result = await db |
| 332 |
.updateTable('desktop_sessions') |
| 333 |
.set({ |
| 334 |
user_id: userId, |
| 335 |
user_type: userType === 'backup' ? 'backup' : 'volunteer', |
| 336 |
}) |
| 337 |
.where('desktop_token', '=', desktopToken) |
| 338 |
.execute(); |
| 339 |
|
| 340 |
return result.length > 0; |
| 341 |
} |
| 342 |
|
| 343 |
async function unlinkDesktopSessionFromUser(desktopToken: string): Promise<boolean> { |
| 344 |
const result = await db |
| 345 |
.updateTable('desktop_sessions') |
| 346 |
.set({ |
| 347 |
user_id: null, |
| 348 |
user_type: null, |
| 349 |
}) |
| 350 |
.where('desktop_token', '=', desktopToken) |
| 351 |
.execute(); |
| 352 |
|
| 353 |
return result.length > 0; |
| 354 |
} |
| 355 |
|
| 356 |
async function updateDesktopConfig( |
| 357 |
desktopToken: string, |
| 358 |
config: Record<string, any> |
| 359 |
): Promise<void> { |
| 360 |
await db |
| 361 |
.updateTable('desktop_sessions') |
| 362 |
.set({ |
| 363 |
storage_config: JSON.stringify(config), |
| 364 |
}) |
| 365 |
.where('desktop_token', '=', desktopToken) |
| 366 |
.execute(); |
| 367 |
} |
| 368 |
|
| 369 |
async function getWebConfigUpdates( |
| 370 |
userId: string | null, |
| 371 |
lastSync: number |
| 372 |
): Promise<Record<string, any>> { |
| 373 |
if (!userId) return {}; |
| 374 |
|
| 375 |
// Get any configuration changes from web interface since last sync |
| 376 |
// This would query user preferences, storage settings, etc. |
| 377 |
const webConfig: Record<string, any> = {}; |
| 378 |
|
| 379 |
// Example: Get user preferences that changed since last sync |
| 380 |
const preferences = await db |
| 381 |
.selectFrom('user_preferences') |
| 382 |
.selectAll() |
| 383 |
.where('user_id', '=', userId) |
| 384 |
.where('updated_at', '>', new Date(lastSync * 1000)) |
| 385 |
.execute(); |
| 386 |
|
| 387 |
for (const pref of preferences) { |
| 388 |
webConfig[pref.key] = pref.value; |
| 389 |
} |
| 390 |
|
| 391 |
return webConfig; |
| 392 |
} |
| 393 |
|
| 394 |
async function deleteDesktopSession(token: string): Promise<void> { |
| 395 |
await db |
| 396 |
.deleteFrom('desktop_sessions') |
| 397 |
.where('desktop_token', '=', token) |
| 398 |
.execute(); |
| 399 |
} |
| 400 |
|
| 401 |
async function getAllDesktopSessions(): Promise<DesktopSession[]> { |
| 402 |
const results = await db |
| 403 |
.selectFrom('desktop_sessions') |
| 404 |
.selectAll() |
| 405 |
.execute(); |
| 406 |
|
| 407 |
return results.map(result => ({ |
| 408 |
id: result.id, |
| 409 |
desktop_token: result.desktop_token, |
| 410 |
user_id: result.user_id, |
| 411 |
user_type: result.user_type as 'backup' | 'volunteer' | null, |
| 412 |
machine_id: result.machine_id, |
| 413 |
app_version: result.app_version, |
| 414 |
os: result.os, |
| 415 |
created_at: result.created_at, |
| 416 |
expires_at: result.expires_at, |
| 417 |
storage_config: result.storage_config ? JSON.parse(result.storage_config) : null, |
| 418 |
})); |
| 419 |
} |
| 420 |
|
| 421 |
async function storeRegistrationFlow(flow: z.infer<typeof RegistrationFlowSchema>): Promise<void> { |
| 422 |
// Store registration flow data temporarily for web interface |
| 423 |
// This could use Redis or a temporary database table |
| 424 |
// For now, just log it |
| 425 |
console.log('Registration flow stored:', flow); |
| 426 |
} |
| 427 |
|
| 428 |
// Cleanup expired desktop sessions (should be called periodically) |
| 429 |
export async function cleanupExpiredDesktopSessions(): Promise<void> { |
| 430 |
await db |
| 431 |
.deleteFrom('desktop_sessions') |
| 432 |
.where('expires_at', '<', new Date()) |
| 433 |
.execute(); |
| 434 |
} |