1// Digital Ocean Spaces Service - S3-compatible object storage for installers
2const AWS = require('aws-sdk');
3const fs = require('fs');
4const path = require('path');
6// Configure DO Spaces (S3-compatible)
7const spacesEndpoint = new AWS.Endpoint(process.env.DO_SPACES_ENDPOINT || 'nyc3.digitaloceanspaces.com');
9 endpoint: spacesEndpoint,
10 accessKeyId: process.env.DO_SPACES_KEY,
11 secretAccessKey: process.env.DO_SPACES_SECRET,
12 region: process.env.DO_SPACES_REGION || 'nyc3',
13 signatureVersion: 'v4'
16const BUCKET_NAME = process.env.DO_SPACES_BUCKET || 'rmm-installers';
19 * Upload a file to DO Spaces
20 * @param {string} filePath - Local file path
21 * @param {string} key - Object key in Spaces (path/filename)
22 * @param {object} options - Additional options (ACL, metadata, etc.)
23 * @returns {Promise<object>} Upload result
25async function uploadFile(filePath, key, options = {}) {
26 if (!fs.existsSync(filePath)) {
27 throw new Error(`File not found: ${filePath}`);
30 const fileContent = fs.readFileSync(filePath);
31 const fileName = path.basename(filePath);
32 const contentType = getContentType(fileName);
38 ContentType: contentType,
39 ACL: options.acl || 'private', // private by default, use signed URLs
40 Metadata: options.metadata || {},
41 CacheControl: options.cacheControl || 'public, max-age=86400', // 24 hours
46 const result = await s3.putObject(params).promise();
47 console.log(`[Spaces] Uploaded ${fileName} to ${key}`);
50 console.error(`[Spaces] Upload failed for ${key}:`, error.message);
56 * Generate a signed URL for secure download
57 * @param {string} key - Object key in Spaces
58 * @param {number} expiresIn - Expiration time in seconds (default: 1 hour)
59 * @returns {string} Signed URL
61function getSignedUrl(key, expiresIn = 3600) {
69 const url = s3.getSignedUrl('getObject', params);
70 console.log(`[Spaces] Generated signed URL for ${key} (expires in ${expiresIn}s)`);
73 console.error(`[Spaces] Failed to generate signed URL for ${key}:`, error.message);
79 * Get a public URL (for public objects with CDN)
80 * @param {string} key - Object key in Spaces
81 * @returns {string} Public URL
83function getPublicUrl(key) {
84 const endpoint = process.env.DO_SPACES_ENDPOINT || 'nyc3.digitaloceanspaces.com';
85 const cdnEndpoint = process.env.DO_SPACES_CDN_ENDPOINT; // Optional CDN endpoint
88 return `https://${cdnEndpoint}/${key}`;
91 return `https://${BUCKET_NAME}.${endpoint}/${key}`;
95 * Check if an object exists in Spaces
96 * @param {string} key - Object key
97 * @returns {Promise<boolean>}
99async function objectExists(key) {
101 await s3.headObject({ Bucket: BUCKET_NAME, Key: key }).promise();
104 if (error.code === 'NotFound') {
112 * Delete an object from Spaces
113 * @param {string} key - Object key
114 * @returns {Promise<object>}
116async function deleteObject(key) {
123 const result = await s3.deleteObject(params).promise();
124 console.log(`[Spaces] Deleted ${key}`);
127 console.error(`[Spaces] Failed to delete ${key}:`, error.message);
133 * List objects in a prefix (folder)
134 * @param {string} prefix - Prefix to filter objects
135 * @param {number} maxKeys - Maximum number of keys to return
136 * @returns {Promise<Array>}
138async function listObjects(prefix = '', maxKeys = 1000) {
146 const result = await s3.listObjectsV2(params).promise();
147 return result.Contents || [];
149 console.error(`[Spaces] Failed to list objects with prefix ${prefix}:`, error.message);
155 * Get object metadata
156 * @param {string} key - Object key
157 * @returns {Promise<object>}
159async function getMetadata(key) {
161 const result = await s3.headObject({ Bucket: BUCKET_NAME, Key: key }).promise();
163 size: result.ContentLength,
164 lastModified: result.LastModified,
165 contentType: result.ContentType,
167 metadata: result.Metadata
170 console.error(`[Spaces] Failed to get metadata for ${key}:`, error.message);
176 * Helper: Determine content type from filename
177 * @param {string} filename
180function getContentType(filename) {
181 const ext = path.extname(filename).toLowerCase();
183 '.exe': 'application/x-msdownload',
184 '.msi': 'application/x-msi',
185 '.zip': 'application/zip',
186 '.tar': 'application/x-tar',
187 '.gz': 'application/gzip',
188 '.json': 'application/json',
189 '.xml': 'application/xml',
192 return types[ext] || 'application/octet-stream';
196 * Upload installer and return signed URL
197 * @param {string} filePath - Local installer file path
198 * @param {string} tenantId - Tenant ID
199 * @param {string} installerType - Type: 'bootstrapper', 'agent', 'tray', etc.
200 * @returns {Promise<object>} { key, signedUrl, publicUrl }
202async function uploadInstaller(filePath, tenantId, installerType = 'agent') {
203 const fileName = path.basename(filePath);
204 const key = `installers/${tenantId}/${installerType}/${fileName}`;
206 await uploadFile(filePath, key, {
209 installerType: installerType,
210 uploadedAt: new Date().toISOString()
212 cacheControl: 'public, max-age=3600' // 1 hour cache
215 const signedUrl = getSignedUrl(key, 3600); // 1 hour expiry
216 const publicUrl = getPublicUrl(key);
227 * Clean up old installers for a tenant
228 * @param {string} tenantId
229 * @param {number} keepRecent - Number of recent installers to keep
231async function cleanupOldInstallers(tenantId, keepRecent = 5) {
232 const prefix = `installers/${tenantId}/`;
233 const objects = await listObjects(prefix);
235 if (objects.length <= keepRecent) {
236 console.log(`[Spaces] No cleanup needed for tenant ${tenantId} (${objects.length} installers)`);
240 // Sort by last modified, keep most recent
241 objects.sort((a, b) => b.LastModified - a.LastModified);
242 const toDelete = objects.slice(keepRecent);
244 console.log(`[Spaces] Cleaning up ${toDelete.length} old installers for tenant ${tenantId}`);
246 for (const obj of toDelete) {
247 await deleteObject(obj.Key);