EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
spacesService.js
Go to the documentation of this file.
1// Digital Ocean Spaces Service - S3-compatible object storage for installers
2const AWS = require('aws-sdk');
3const fs = require('fs');
4const path = require('path');
5
6// Configure DO Spaces (S3-compatible)
7const spacesEndpoint = new AWS.Endpoint(process.env.DO_SPACES_ENDPOINT || 'nyc3.digitaloceanspaces.com');
8const s3 = new AWS.S3({
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'
14});
15
16const BUCKET_NAME = process.env.DO_SPACES_BUCKET || 'rmm-installers';
17
18/**
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
24 */
25async function uploadFile(filePath, key, options = {}) {
26 if (!fs.existsSync(filePath)) {
27 throw new Error(`File not found: ${filePath}`);
28 }
29
30 const fileContent = fs.readFileSync(filePath);
31 const fileName = path.basename(filePath);
32 const contentType = getContentType(fileName);
33
34 const params = {
35 Bucket: BUCKET_NAME,
36 Key: key,
37 Body: fileContent,
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
42 ...options
43 };
44
45 try {
46 const result = await s3.putObject(params).promise();
47 console.log(`[Spaces] Uploaded ${fileName} to ${key}`);
48 return result;
49 } catch (error) {
50 console.error(`[Spaces] Upload failed for ${key}:`, error.message);
51 throw error;
52 }
53}
54
55/**
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
60 */
61function getSignedUrl(key, expiresIn = 3600) {
62 const params = {
63 Bucket: BUCKET_NAME,
64 Key: key,
65 Expires: expiresIn
66 };
67
68 try {
69 const url = s3.getSignedUrl('getObject', params);
70 console.log(`[Spaces] Generated signed URL for ${key} (expires in ${expiresIn}s)`);
71 return url;
72 } catch (error) {
73 console.error(`[Spaces] Failed to generate signed URL for ${key}:`, error.message);
74 throw error;
75 }
76}
77
78/**
79 * Get a public URL (for public objects with CDN)
80 * @param {string} key - Object key in Spaces
81 * @returns {string} Public URL
82 */
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
86
87 if (cdnEndpoint) {
88 return `https://${cdnEndpoint}/${key}`;
89 }
90
91 return `https://${BUCKET_NAME}.${endpoint}/${key}`;
92}
93
94/**
95 * Check if an object exists in Spaces
96 * @param {string} key - Object key
97 * @returns {Promise<boolean>}
98 */
99async function objectExists(key) {
100 try {
101 await s3.headObject({ Bucket: BUCKET_NAME, Key: key }).promise();
102 return true;
103 } catch (error) {
104 if (error.code === 'NotFound') {
105 return false;
106 }
107 throw error;
108 }
109}
110
111/**
112 * Delete an object from Spaces
113 * @param {string} key - Object key
114 * @returns {Promise<object>}
115 */
116async function deleteObject(key) {
117 const params = {
118 Bucket: BUCKET_NAME,
119 Key: key
120 };
121
122 try {
123 const result = await s3.deleteObject(params).promise();
124 console.log(`[Spaces] Deleted ${key}`);
125 return result;
126 } catch (error) {
127 console.error(`[Spaces] Failed to delete ${key}:`, error.message);
128 throw error;
129 }
130}
131
132/**
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>}
137 */
138async function listObjects(prefix = '', maxKeys = 1000) {
139 const params = {
140 Bucket: BUCKET_NAME,
141 Prefix: prefix,
142 MaxKeys: maxKeys
143 };
144
145 try {
146 const result = await s3.listObjectsV2(params).promise();
147 return result.Contents || [];
148 } catch (error) {
149 console.error(`[Spaces] Failed to list objects with prefix ${prefix}:`, error.message);
150 throw error;
151 }
152}
153
154/**
155 * Get object metadata
156 * @param {string} key - Object key
157 * @returns {Promise<object>}
158 */
159async function getMetadata(key) {
160 try {
161 const result = await s3.headObject({ Bucket: BUCKET_NAME, Key: key }).promise();
162 return {
163 size: result.ContentLength,
164 lastModified: result.LastModified,
165 contentType: result.ContentType,
166 etag: result.ETag,
167 metadata: result.Metadata
168 };
169 } catch (error) {
170 console.error(`[Spaces] Failed to get metadata for ${key}:`, error.message);
171 throw error;
172 }
173}
174
175/**
176 * Helper: Determine content type from filename
177 * @param {string} filename
178 * @returns {string}
179 */
180function getContentType(filename) {
181 const ext = path.extname(filename).toLowerCase();
182 const types = {
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',
190 '.txt': 'text/plain'
191 };
192 return types[ext] || 'application/octet-stream';
193}
194
195/**
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 }
201 */
202async function uploadInstaller(filePath, tenantId, installerType = 'agent') {
203 const fileName = path.basename(filePath);
204 const key = `installers/${tenantId}/${installerType}/${fileName}`;
205
206 await uploadFile(filePath, key, {
207 metadata: {
208 tenantId: tenantId,
209 installerType: installerType,
210 uploadedAt: new Date().toISOString()
211 },
212 cacheControl: 'public, max-age=3600' // 1 hour cache
213 });
214
215 const signedUrl = getSignedUrl(key, 3600); // 1 hour expiry
216 const publicUrl = getPublicUrl(key);
217
218 return {
219 key,
220 signedUrl,
221 publicUrl,
222 fileName
223 };
224}
225
226/**
227 * Clean up old installers for a tenant
228 * @param {string} tenantId
229 * @param {number} keepRecent - Number of recent installers to keep
230 */
231async function cleanupOldInstallers(tenantId, keepRecent = 5) {
232 const prefix = `installers/${tenantId}/`;
233 const objects = await listObjects(prefix);
234
235 if (objects.length <= keepRecent) {
236 console.log(`[Spaces] No cleanup needed for tenant ${tenantId} (${objects.length} installers)`);
237 return;
238 }
239
240 // Sort by last modified, keep most recent
241 objects.sort((a, b) => b.LastModified - a.LastModified);
242 const toDelete = objects.slice(keepRecent);
243
244 console.log(`[Spaces] Cleaning up ${toDelete.length} old installers for tenant ${tenantId}`);
245
246 for (const obj of toDelete) {
247 await deleteObject(obj.Key);
248 }
249}
250
251module.exports = {
252 uploadFile,
253 getSignedUrl,
254 getPublicUrl,
255 objectExists,
256 deleteObject,
257 listObjects,
258 getMetadata,
259 uploadInstaller,
260 cleanupOldInstallers
261};