2 * @file Agent Installer Download and Distribution Service
4 * Provides agent installer generation, storage, and distribution endpoints for RMM agent
5 * deployment across multiple tenants. Supports both local file serving and DigitalOcean
6 * Spaces CDN distribution with automatic installer building, caching, and signed URL generation.
9 * - **Dynamic installer generation**: On-demand EXE build with tenant-specific configuration
10 * - **DigitalOcean Spaces integration**: CDN-backed installer storage for global distribution
11 * - **Multi-installer types**: Bootstrapper (dynamic/static), full agent, quick batch scripts
12 * - **JWT-signed downloads**: Secure installer downloads with embedded tenant authentication
13 * - **Build caching**: Reuses existing installers to reduce build time and server load
14 * - **Admin management**: Upload, list, and cleanup installer artifacts
17 * - **Bootstrapper Dynamic**: Small EXE that downloads full agent on target machine
18 * - **Bootstrapper Static**: Self-contained EXE with agent binary embedded
19 * - **Generic Bootstrapper**: No tenant ID, prompts user during installation
20 * - **Quick Install Batch**: Windows batch script with PowerShell download and install
21 * - **Bootstrap PowerShell**: PowerShell script for automated deployment
22 * - **Dev Agent**: Full agent installer for development testing
25 * - **Local**: Installers stored in backend/downloads directory, served directly
26 * - **Spaces**: Installers uploaded to DigitalOcean Spaces, accessed via signed URLs
27 * - Controlled by `USE_DO_SPACES` environment variable
30 * 1. Check if installer exists in Spaces (if enabled) or local cache
31 * 2. If exists: Generate signed URL (Spaces) or serve file (local)
32 * 3. If not exists: Build installer using Node.js builder scripts
33 * 4. Upload to Spaces (if enabled) or cache locally
34 * 5. Redirect to signed URL or serve file
37 * - JWT tokens signed with AGENT_SIGN_KEY for tenant authentication
38 * - Signed URLs expire after 1 hour (configurable)
39 * - Path traversal prevention for local file operations
40 * - Admin endpoints require authentication
42 * Environment variables:
43 * - `USE_DO_SPACES`: Enable DigitalOcean Spaces distribution (default: false)
44 * - `AGENT_SIGN_KEY`: JWT signing key for agent authentication tokens
45 * - `BACKEND_URL`: API base URL embedded in installers
46 * - `CLEANUP_AFTER_UPLOAD`: Delete local files after Spaces upload (default: false)
47 * @module routes/download-spaces
49 * @requires ../middleware/auth - JWT authentication for admin endpoints
50 * @requires ../services/spacesService - DigitalOcean Spaces upload/download client
51 * @requires child_process - Execute installer builder scripts
52 * @see {@link module:services/spacesService}
55const express = require('express');
56const path = require('path');
57const fs = require('fs');
58const authenticateToken = require('../middleware/auth');
59const router = express.Router();
60const { exec, execFileSync } = require('child_process');
61const os = require('os');
62const spacesService = require('../services/spacesService');
64// Configuration: Use DO Spaces or local file serving
65const USE_SPACES = process.env.USE_DO_SPACES === 'true' || false;
66const jwt = require('jsonwebtoken');
67const AGENT_SIGN_KEY = process.env.AGENT_SIGN_KEY;
70 * Helper function to build, cache, and serve agent installers.
72 * Implements hybrid storage strategy:
73 * - Checks Spaces for existing installer (if USE_SPACES=true)
74 * - Falls back to local cache check
75 * - Builds installer if not found anywhere
76 * - Uploads to Spaces and redirects to signed URL (if USE_SPACES=true)
77 * - Serves local file if Spaces disabled
80 * @function buildAndServeInstaller
81 * @param {express.Request} req - Express request object
82 * @param {express.Response} res - Express response object
83 * @param {string} builderScript - Path to Node.js builder script (e.g., './builders/buildBootstrapper.js')
84 * @param {string} outputPath - Local filesystem path for built installer (must be in backend/downloads)
85 * @param {string} downloadName - Filename for downloaded installer (e.g., 'IBG-Agent-Setup-123.exe')
86 * @param {string} tenantId - Tenant identifier for multi-tenant isolation
87 * @param {string} installerType - Installer type for storage organization (e.g., 'bootstrapper-dynamic')
88 * @returns {Promise<void>} Sends file download or redirects to signed URL
89 * @throws {Error} If output path is outside backend/downloads (security check)
90 * @throws {Error} If builder script execution fails or times out (4-minute timeout)
94 * Helper: Build and optionally upload installer
97 * @param builderScript
101 * @param installerType
103async function buildAndServeInstaller(req, res, builderScript, outputPath, downloadName, tenantId, installerType) {
104 const downloadsDir = path.resolve(__dirname, '../downloads');
107 if (!outputPath.startsWith(downloadsDir)) {
108 console.error(`[INSTALLER] Output path is not in backend/downloads: ${outputPath}`);
109 return res.status(500).json({ error: 'Output path misconfiguration' });
113 // Check if already exists in Spaces
114 if (USE_SPACES && tenantId) {
115 const key = `installers/${tenantId}/${installerType}/${downloadName}`;
116 const exists = await spacesService.objectExists(key);
119 console.log(`[INSTALLER] Found existing installer in Spaces: ${key}`);
120 const signedUrl = spacesService.getSignedUrl(key, 3600);
121 return res.redirect(signedUrl);
125 // Check if already built locally
126 if (fs.existsSync(outputPath)) {
127 if (USE_SPACES && tenantId) {
128 // Upload to Spaces and redirect
129 console.log(`[INSTALLER] Uploading existing local installer to Spaces`);
130 const result = await spacesService.uploadInstaller(outputPath, tenantId, installerType);
131 return res.redirect(result.signedUrl);
134 console.log(`[INSTALLER] Serving existing local installer: ${outputPath}`);
135 return res.download(outputPath, downloadName);
139 // Build the installer
140 console.log(`[INSTALLER] Building ${installerType} for tenant ${tenantId || 'generic'}`);
141 execFileSync('node', [builderScript, tenantId, outputPath], {
143 timeout: 4 * 60 * 1000,
144 maxBuffer: 10 * 1024 * 1024
147 console.log(`[INSTALLER] Build complete: ${outputPath}`);
149 // Upload to Spaces or serve locally
150 if (USE_SPACES && tenantId) {
151 const result = await spacesService.uploadInstaller(outputPath, tenantId, installerType);
152 console.log(`[INSTALLER] Uploaded to Spaces, redirecting to signed URL`);
154 // Optionally cleanup local file after upload
155 if (process.env.CLEANUP_AFTER_UPLOAD === 'true') {
156 fs.unlinkSync(outputPath);
157 console.log(`[INSTALLER] Cleaned up local file: ${outputPath}`);
160 return res.redirect(result.signedUrl);
162 return res.download(outputPath, downloadName);
166 console.error(`[INSTALLER] Build or serve failed: ${err.message}`);
167 return res.status(500).json({ error: 'Build or serve failed', details: err.message });
172 * @api {get} /download/bootstrapper-dynamic/:tenantId.exe Download Dynamic Bootstrapper
173 * @apiName GetBootstrapperDynamic
174 * @apiGroup AgentInstaller
176 * Generates or serves tenant-specific dynamic bootstrapper EXE. Dynamic bootstrappers are
177 * lightweight executables that download the full agent installer on the target machine,
178 * reducing initial download size and enabling always-current agent deployments.
181 * - Generates JWT token for tenant authentication (30-day expiration)
182 * - Builds EXE using build_bootstrapper.js if not cached
183 * - Uploads to DigitalOcean Spaces (if USE_DO_SPACES=true)
184 * - Serves cached version on subsequent requests
185 * @apiPermission public
186 * @apiParam {string} tenantId Tenant identifier for bootstrapper customization
187 * @apiSuccess (200) {Binary} file Dynamic bootstrapper executable (EXE format)
188 * @apiHeader {string} Content-Disposition attachment; filename="bootstrapper_{tenantId}.exe"
189 * @apiHeader {string} Content-Type application/octet-stream
190 * @apiError (400) {String} error "Missing tenantId"
191 * @apiError (500) {String} error "Missing AGENT_SIGN_KEY" (if JWT signing key not configured)
192 * @apiError (500) {String} error "Build or serve failed"
193 * @apiError (500) {String} details Detailed build error message
194 * @apiExample {curl} Example Request:
195 * curl -o installer.exe https://api.ibghub.com/api/download/bootstrapper-dynamic/123.exe
198// Dynamic bootstrapper EXE generator
199router.get('/bootstrapper-dynamic/:tenantId.exe', async (req, res) => {
200 const tenantId = req.params.tenantId;
201 console.log(`[BOOTSTRAPPER] Incoming request for tenantId: ${tenantId}`);
203 req.setTimeout(5 * 60 * 1000);
204 res.setTimeout(5 * 60 * 1000);
207 console.error('[BOOTSTRAPPER] Missing tenantId');
208 return res.status(400).json({ error: 'Missing tenantId' });
210 if (!AGENT_SIGN_KEY) {
211 console.error('[BOOTSTRAPPER] Missing AGENT_SIGN_KEY');
212 return res.status(500).json({ error: 'Missing AGENT_SIGN_KEY' });
215 const token = jwt.sign({ tenantId }, AGENT_SIGN_KEY, { expiresIn: '30d' });
216 console.log(`[BOOTSTRAPPER] Generated JWT for tenantId: ${tenantId}`);
218 const builderScript = path.resolve(__dirname, '../downloads/build_bootstrapper.js');
219 const downloadsDir = path.resolve(__dirname, '../downloads');
220 const outputExe = path.join(downloadsDir, `bootstrapper_${tenantId}.exe`);
221 const downloadName = `bootstrapper_${tenantId}.exe`;
223 await buildAndServeInstaller(
234 * @api {get} /download/bootstrapper-static/:tenantId.exe Download Static Bootstrapper
235 * @apiName GetBootstrapperStatic
236 * @apiGroup AgentInstaller
238 * Serves pre-built tenant-specific static bootstrapper EXE from cache. Static bootstrappers
239 * are self-contained executables with agent binary embedded, suitable for offline deployment
240 * or air-gapped environments. Must be pre-built and uploaded by administrators.
241 * @apiPermission public
242 * @apiParam {string} tenantId Tenant identifier
243 * @apiSuccess (200) {Binary} file Static bootstrapper executable
244 * @apiError (404) {String} error "Bootstrapper not found" (if not pre-built/uploaded)
245 * @apiExample {curl} Example Request:
246 * curl -o installer.exe https://api.ibghub.com/api/download/bootstrapper-static/123.exe
249// Serve tenant-specific static bootstrapper EXE
250router.get('/bootstrapper-static/:tenantId.exe', async (req, res) => {
251 const tenantId = req.params.tenantId;
252 const downloadName = `bootstrapper_${tenantId}.exe`;
255 const key = `installers/${tenantId}/bootstrapper/${downloadName}`;
256 const exists = await spacesService.objectExists(key);
259 const signedUrl = spacesService.getSignedUrl(key, 3600);
260 return res.redirect(signedUrl);
264 const filePath = path.resolve(__dirname, `../downloads/${downloadName}`);
265 if (!fs.existsSync(filePath)) {
266 return res.status(404).json({ error: 'Bootstrapper not found' });
268 res.download(filePath, downloadName);
272 * @api {get} /download/bootstrapper.exe Download Generic Bootstrapper
273 * @apiName GetBootstrapperGeneric
274 * @apiGroup AgentInstaller
276 * Serves generic (non-tenant-specific) bootstrapper EXE. User prompted for tenant credentials
277 * during installation, similar to Syncro/N-Central/Kaseya deployment model. Useful for
278 * partner portals, downloads sections, or scenarios where tenant ID is unknown at download time.
279 * @apiPermission public
280 * @apiNote No authentication required - suitable for public download pages
281 * @apiSuccess (200) {Binary} file Generic bootstrapper executable
282 * @apiError (404) {String} error "Bootstrapper not found" (if not built)
283 * @apiExample {curl} Example Request:
284 * curl -o installer.exe https://api.ibghub.com/api/download/bootstrapper.exe
287// Serve static bootstrapper EXE for Syncro-style installer (no auth)
288router.get('/bootstrapper.exe', async (req, res) => {
289 const downloadName = 'bootstrapper.exe';
292 const key = `installers/public/${downloadName}`;
293 const exists = await spacesService.objectExists(key);
296 const signedUrl = spacesService.getSignedUrl(key, 3600);
297 return res.redirect(signedUrl);
301 const bootstrapperPath = path.resolve(__dirname, `../downloads/${downloadName}`);
302 if (!fs.existsSync(bootstrapperPath)) {
303 return res.status(404).json({ error: 'Bootstrapper not found' });
305 res.download(bootstrapperPath, downloadName);
309 * @api {get} /download/dev-agent/quick/:tenantId Download Quick Install Batch Script
310 * @apiName GetQuickInstallBatch
311 * @apiGroup AgentInstaller
313 * Generates Windows batch script (.bat) for one-click agent installation. Script creates
314 * bootstrap.json with tenant configuration and JWT token, downloads full agent installer
315 * via PowerShell Invoke-WebRequest, and runs silent installation. Ideal for email distribution,
316 * documentation, or helpdesk guided installations.
318 * Generated batch script:
319 * - Creates bootstrap.json in %PROGRAMDATA%\EverydayTechAgent with tenantId, JWT, backend URL
320 * - Downloads agent installer using PowerShell
321 * - Runs Inno Setup installer with /VERYSILENT flag
322 * - Displays progress messages and error handling
323 * @apiPermission public
324 * @apiParam {string} tenantId Tenant identifier for agent registration
325 * @apiSuccess (200) {string} script Windows batch script content
326 * @apiHeader {string} Content-Type application/octet-stream
327 * @apiHeader {string} Content-Disposition attachment; filename="Install_Agent.bat"
328 * @apiError (400) {String} error "Missing tenantId"
329 * @apiError (500) {String} error "Missing AGENT_SIGN_KEY"
330 * @apiError (500) {String} error "Failed to generate installer"
331 * @apiExample {curl} Example Request:
332 * curl -o Install_Agent.bat https://api.ibghub.com/api/download/dev-agent/quick/123
333 * @apiExample {batch} Generated Script:
335 * REM EverydayTech Agent Quick Install
336 * echo { > "%PROGRAMDATA%\EverydayTechAgent\bootstrap.json"
337 * echo "tenantId": "123", >> "%PROGRAMDATA%\EverydayTechAgent\bootstrap.json"
338 * echo "jwt": "eyJhbGciOi...", >> "%PROGRAMDATA%\EverydayTechAgent\bootstrap.json"
339 * powershell -Command "Invoke-WebRequest -Uri 'https://api.ibghub.com/api/download/dev-agent' -OutFile '%TEMP%\Agent.exe'"
340 * "%TEMP%\Agent.exe" /VERYSILENT
343// Quick install - downloads batch script that fetches installer
344router.get('/dev-agent/quick/:tenantId', async (req, res) => {
345 const tenantId = req.params.tenantId;
348 return res.status(400).json({ error: 'Missing tenantId' });
351 if (!AGENT_SIGN_KEY) {
352 return res.status(500).json({ error: 'Missing AGENT_SIGN_KEY' });
355 const jwtToken = jwt.sign({ tenantId }, AGENT_SIGN_KEY, { expiresIn: '365d' });
358 console.log(`[QUICK-INSTALL] Generating batch installer for tenant: ${tenantId}`);
360 const backendUrl = process.env.BACKEND_URL || 'https://everydaytech.au/api';
362 const batchScript = `@echo off
363REM EverydayTech Agent Quick Install
364REM Generated for tenant: ${tenantId}
367echo ========================================
368echo EverydayTech Agent Installer
369echo ========================================
372REM Create bootstrap.json in agent data directory
373echo Creating bootstrap configuration...
374set "DATADIR=%PROGRAMDATA%\\EverydayTechAgent"
375if not exist "%DATADIR%" mkdir "%DATADIR%"
377echo { > "%DATADIR%\\bootstrap.json"
378echo "tenantId": "${tenantId}", >> "%DATADIR%\\bootstrap.json"
379echo "jwt": "${jwtToken}", >> "%DATADIR%\\bootstrap.json"
380echo "backend": "${backendUrl}" >> "%DATADIR%\\bootstrap.json"
381echo } >> "%DATADIR%\\bootstrap.json"
383echo Bootstrap configuration created.
386echo Downloading installer...
387set "INSTALLER=%TEMP%\\EverydayTechAgent_Installer.exe"
389powershell -NoProfile -ExecutionPolicy Bypass -Command "Invoke-WebRequest -Uri '${backendUrl}/download/dev-agent' -OutFile '%INSTALLER%' -UseBasicParsing"
391if not exist "%INSTALLER%" (
393 echo ERROR: Download failed. Please check your internet connection.
400echo Starting installation...
403REM Run Inno Setup installer (will detect bootstrap.json)
404"%INSTALLER%" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
407echo Installation complete! The agent will appear in your dashboard shortly.
413 res.setHeader('Content-Type', 'application/octet-stream');
414 res.setHeader('Content-Disposition', 'attachment; filename="Install_Agent.bat"');
415 res.send(batchScript);
418 console.error(`[QUICK-INSTALL] Failed to generate installer: ${err.message}`);
419 return res.status(500).json({ error: 'Failed to generate installer', details: err.message });
424 * @api {get} /download/dev-agent/bootstrap.ps1 Download Bootstrap PowerShell Script
425 * @apiName GetBootstrapPowerShell
426 * @apiGroup AgentInstaller
428 * Generates PowerShell script (.ps1) for agent deployment via automation tools (RMM scripts,
429 * GPO, SCCM, Intune). Legacy endpoint requiring tenant and token as query parameters.
430 * Script downloads installer and runs with tenant/token arguments.
431 * @apiPermission public
432 * @apiQuery {string} tenant Tenant identifier
433 * @apiQuery {string} token Authentication token
434 * @apiSuccess (200) {string} script PowerShell script content
435 * @apiHeader {string} Content-Type application/x-powershell
436 * @apiHeader {string} Content-Disposition attachment; filename="agent_bootstrap_{tenant}.ps1"
437 * @apiError (400) {String} error "Missing ?tenant="
438 * @apiError (400) {String} error "Missing ?token="
439 * @apiExample {curl} Example Request:
440 * curl -o bootstrap.ps1 "https://api.ibghub.com/api/download/dev-agent/bootstrap.ps1?tenant=123&token=abc123"
443// Legacy PowerShell bootstrap script generator
444router.get('/dev-agent/bootstrap.ps1', async (req, res) => {
445 const { tenant, token } = req.query;
447 return res.status(400).json({ error: 'Missing ?tenant=' });
449 return res.status(400).json({ error: 'Missing ?token=' });
451 const backendUrl = process.env.BACKEND_URL || 'https://everydaytech.au/api';
452 const installerUrl = `${backendUrl}/download/dev-agent?tenant=${encodeURIComponent(tenant)}&token=${encodeURIComponent(token)}`;
457$installer = "$env:TEMP\\EverydayTechAgent_Installer.exe"
459Invoke-WebRequest "${installerUrl}" -OutFile $installer
460Start-Process $installer -ArgumentList "/tenant=$tenant /token=$token" -Wait
462 res.setHeader('Content-Type', 'application/x-powershell');
463 res.setHeader('Content-Disposition', `attachment; filename=agent_bootstrap_${tenant}.ps1`);
468 * @api {get} /download/dev-agent Download Full Agent Installer
469 * @apiName GetFullAgentInstaller
470 * @apiGroup AgentInstaller
472 * Downloads complete agent installer EXE. This is the full-featured agent installer built
473 * with Inno Setup, used by bootstrappers and quick install scripts. Can also be downloaded
474 * directly for manual installation or distribution via other channels.
475 * @apiPermission public
476 * @apiNote Optional query parameters (tenant, token) for legacy compatibility but not enforced
477 * @apiQuery {string} [tenant] Legacy tenant identifier (not required)
478 * @apiQuery {string} [token] Legacy authentication token (not required)
479 * @apiSuccess (200) {Binary} file Full agent installer executable (dev-agent.exe)
480 * @apiError (404) {String} error "Installer not built yet. Run: builder/dev/installer/build_installer.sh"
481 * @apiExample {curl} Example Request:
482 * curl -o dev-agent.exe https://api.ibghub.com/api/download/dev-agent
485// Main agent installer download
486router.get('/dev-agent', async (req, res) => {
487 const { tenant, token } = req.query;
488 const downloadName = 'dev-agent.exe';
491 const key = `installers/public/agent/${downloadName}`;
492 const exists = await spacesService.objectExists(key);
495 const signedUrl = spacesService.getSignedUrl(key, 3600);
496 return res.redirect(signedUrl);
500 const installerPath = path.resolve(__dirname, `../downloads/${downloadName}`);
502 if (!fs.existsSync(installerPath)) {
503 return res.status(404).json({
504 error: 'Installer not built yet. Run: builder/dev/installer/build_installer.sh'
508 res.download(installerPath, downloadName);
512 * @api {post} /download/admin/upload-installer/:installerType Upload Installer to Spaces
513 * @apiName UploadInstallerToSpaces
514 * @apiGroup AgentInstaller
516 * Administrative endpoint to upload locally-built installer to DigitalOcean Spaces CDN.
517 * Supports multipart file upload with automatic path organization by tenant and installer type.
518 * Used for pre-building static bootstrappers or uploading custom agent builds.
519 * @apiPermission admin
521 * @apiParam {String="bootstrapper","agent","custom"} installerType Type of installer being uploaded
522 * @apiBody {string} tenantId Tenant identifier for installer attribution
523 * @apiBody {File} file Installer file (EXE) via multipart/form-data
524 * @apiSuccess {boolean} success Operation success status (true)
525 * @apiSuccess {string} message Success message
526 * @apiSuccess {string} key DigitalOcean Spaces object key
527 * @apiSuccess {string} signedUrl Time-limited signed URL for installer download
528 * @apiError (400) {String} error "No file uploaded"
529 * @apiError (400) {String} error "Missing tenantId in request body"
530 * @apiError (500) {String} error Upload failure message
531 * @apiError (500) {String} details Detailed error description
532 * @apiExample {curl} Example Request:
533 * curl -X POST https://api.ibghub.com/api/download/admin/upload-installer/bootstrapper \
534 * -H "Authorization: Bearer YOUR_JWT_TOKEN" \
535 * -F "tenantId=123" \
536 * -F "file=@/path/to/bootstrapper.exe"
539// Upload installer to Spaces (admin only)
540router.post('/admin/upload-installer/:installerType', authenticateToken, async (req, res) => {
542 return res.status(400).json({ error: 'DO Spaces not enabled' });
545 const { installerType } = req.params;
546 const { fileName, tenantId } = req.body;
549 return res.status(400).json({ error: 'Missing fileName' });
552 const filePath = path.resolve(__dirname, '../downloads', fileName);
554 if (!fs.existsSync(filePath)) {
555 return res.status(404).json({ error: 'File not found' });
559 const result = await spacesService.uploadInstaller(
561 tenantId || 'public',
568 signedUrl: result.signedUrl,
569 fileName: result.fileName
572 console.error('[UPLOAD] Failed:', error.message);
573 res.status(500).json({ error: 'Upload failed', details: error.message });
578 * @api {get} /download/admin/list-installers/:tenantId? List Installers in Spaces
579 * @apiName ListInstallersInSpaces
580 * @apiGroup AgentInstaller
582 * Administrative endpoint to list all installers stored in DigitalOcean Spaces. Optionally
583 * filter by tenant ID. Returns metadata including file keys, sizes, last modified timestamps,
584 * and signed download URLs. Useful for audit trails, inventory management, and cleanup planning.
585 * @apiPermission admin
587 * @apiParam {string} [tenantId] Optional tenant ID to filter installers
588 * @apiSuccess {object[]} installers Array of installer objects from Spaces
589 * @apiSuccess {string} installers.key Object storage key (e.g., "installers/123/bootstrapper/file.exe")
590 * @apiSuccess {number} installers.size File size in bytes
591 * @apiSuccess {string} installers.lastModified ISO timestamp of last modification
592 * @apiSuccess {string} installers.signedUrl Time-limited download URL (1-hour expiration)
593 * @apiError (400) {String} error "DO Spaces not enabled" (if USE_DO_SPACES=false)
594 * @apiError (500) {String} error "List failed"
595 * @apiError (500) {String} details Detailed error from Spaces API
596 * @apiExample {curl} Example Request (all installers):
597 * curl -X GET https://api.ibghub.com/api/download/admin/list-installers \
598 * -H "Authorization: Bearer YOUR_JWT_TOKEN"
599 * @apiExample {curl} Example Request (tenant-specific):
600 * curl -X GET https://api.ibghub.com/api/download/admin/list-installers/123 \
601 * -H "Authorization: Bearer YOUR_JWT_TOKEN"
604// List installers in Spaces (admin only)
605router.get('/admin/list-installers/:tenantId?', authenticateToken, async (req, res) => {
607 return res.status(400).json({ error: 'DO Spaces not enabled' });
610 const { tenantId } = req.params;
611 const prefix = tenantId ? `installers/${tenantId}/` : 'installers/';
614 const objects = await spacesService.listObjects(prefix);
615 const installers = objects.map(obj => ({
618 lastModified: obj.LastModified,
619 signedUrl: spacesService.getSignedUrl(obj.Key, 3600)
622 res.json({ installers });
624 console.error('[LIST] Failed:', error.message);
625 res.status(500).json({ error: 'List failed', details: error.message });
630 * @api {post} /download/admin/cleanup-installers/:tenantId Cleanup Old Installers
631 * @apiName CleanupOldInstallers
632 * @apiGroup AgentInstaller
634 * Administrative endpoint to delete old installer versions from DigitalOcean Spaces to
635 * free storage and reduce costs. Keeps most recent N versions (default: 5) and deletes
636 * older installers for specified tenant. Use with caution as operation is irreversible.
637 * @apiPermission admin
639 * @apiParam {string} tenantId Tenant identifier for cleanup scope
640 * @apiBody {number} [keepRecent=5] Number of recent installer versions to retain
641 * @apiSuccess {boolean} success Operation success status (true)
642 * @apiSuccess {string} message Success message with tenant ID
643 * @apiError (400) {String} error "DO Spaces not enabled" (if USE_DO_SPACES=false)
644 * @apiError (500) {String} error "Cleanup failed"
645 * @apiError (500) {String} details Detailed error from Spaces API
646 * @apiExample {curl} Example Request (keep 5 recent):
647 * curl -X POST https://api.ibghub.com/api/download/admin/cleanup-installers/123 \
648 * -H "Authorization: Bearer YOUR_JWT_TOKEN" \
649 * -H "Content-Type: application/json" \
650 * -d '{"keepRecent": 5}'
651 * @apiExample {json} Example Response:
654 * "message": "Cleaned up old installers for 123"
658// Cleanup old installers (admin only)
659router.post('/admin/cleanup-installers/:tenantId', authenticateToken, async (req, res) => {
661 return res.status(400).json({ error: 'DO Spaces not enabled' });
664 const { tenantId } = req.params;
665 const { keepRecent } = req.body;
668 await spacesService.cleanupOldInstallers(tenantId, keepRecent || 5);
669 res.json({ success: true, message: `Cleaned up old installers for ${tenantId}` });
671 console.error('[CLEANUP] Failed:', error.message);
672 res.status(500).json({ error: 'Cleanup failed', details: error.message });
676module.exports = router;