@xenterprises/fastify-xstorage 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +28 -0
- package/EXAMPLES.md +797 -0
- package/QUICK_START.md +345 -0
- package/README.md +492 -0
- package/TESTING.md +567 -0
- package/package.json +51 -0
- package/server/app.js +136 -0
- package/src/index.js +9 -0
- package/src/services/storage.js +352 -0
- package/src/utils/helpers.js +238 -0
- package/src/xStorage.js +86 -0
- package/test/storage.test.js +257 -0
package/server/app.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// server/app.js
|
|
2
|
+
import Fastify from "fastify";
|
|
3
|
+
import multipart from "@fastify/multipart";
|
|
4
|
+
import xStorage from "../src/xStorage.js";
|
|
5
|
+
|
|
6
|
+
const fastify = Fastify({
|
|
7
|
+
logger: true,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// xStorage is a library of methods, not a server with routes
|
|
11
|
+
// The routes below are EXAMPLES of how to use the fastify.xStorage methods
|
|
12
|
+
// You can implement your own routes or use xStorage methods directly in your application
|
|
13
|
+
|
|
14
|
+
// Register multipart for file uploads
|
|
15
|
+
await fastify.register(multipart, {
|
|
16
|
+
limits: {
|
|
17
|
+
fileSize: 10 * 1024 * 1024, // 10MB
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Register xStorage
|
|
22
|
+
await fastify.register(xStorage, {
|
|
23
|
+
endpoint: process.env.STORAGE_ENDPOINT,
|
|
24
|
+
region: process.env.STORAGE_REGION,
|
|
25
|
+
accessKeyId: process.env.STORAGE_ACCESS_KEY_ID,
|
|
26
|
+
secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY,
|
|
27
|
+
bucket: process.env.STORAGE_BUCKET,
|
|
28
|
+
publicUrl: process.env.STORAGE_PUBLIC_URL,
|
|
29
|
+
forcePathStyle: true,
|
|
30
|
+
// Default ACL is "private" - use signed URLs for secure access
|
|
31
|
+
// acl: "private", // Uncomment to override default
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Basic file upload endpoint
|
|
35
|
+
fastify.post("/upload", async (request, reply) => {
|
|
36
|
+
const data = await request.file();
|
|
37
|
+
|
|
38
|
+
if (!data) {
|
|
39
|
+
return reply.code(400).send({ error: "No file uploaded" });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const buffer = await data.toBuffer();
|
|
43
|
+
|
|
44
|
+
const result = await fastify.xStorage.upload(buffer, data.filename, {
|
|
45
|
+
folder: "uploads",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
success: true,
|
|
50
|
+
file: result,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// For image processing, use @xenterprises/fastify-ximagepipeline
|
|
55
|
+
// Example:
|
|
56
|
+
// import xImagePipeline from "@xenterprises/fastify-ximagepipeline";
|
|
57
|
+
// await fastify.register(xImagePipeline, { r2, db, ... });
|
|
58
|
+
|
|
59
|
+
// Multiple file upload
|
|
60
|
+
fastify.post("/upload/multiple", async (request, reply) => {
|
|
61
|
+
const parts = request.parts();
|
|
62
|
+
const files = [];
|
|
63
|
+
|
|
64
|
+
for await (const part of parts) {
|
|
65
|
+
if (part.type === "file") {
|
|
66
|
+
const buffer = await part.toBuffer();
|
|
67
|
+
files.push({
|
|
68
|
+
file: buffer,
|
|
69
|
+
filename: part.filename,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const results = await fastify.xStorage.uploadMultiple(files, {
|
|
75
|
+
folder: "uploads",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
success: true,
|
|
80
|
+
files: results,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// List files
|
|
85
|
+
fastify.get("/files", async (request, reply) => {
|
|
86
|
+
const { folder = "" } = request.query;
|
|
87
|
+
|
|
88
|
+
const files = await fastify.xStorage.list(folder);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
success: true,
|
|
92
|
+
files,
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Delete file
|
|
97
|
+
fastify.delete("/files/:key", async (request, reply) => {
|
|
98
|
+
const { key } = request.params;
|
|
99
|
+
|
|
100
|
+
await fastify.xStorage.delete(key);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
success: true,
|
|
104
|
+
message: "File deleted successfully",
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Get signed URL
|
|
109
|
+
fastify.get("/files/:key/signed-url", async (request, reply) => {
|
|
110
|
+
const { key } = request.params;
|
|
111
|
+
const { expires = 3600 } = request.query;
|
|
112
|
+
|
|
113
|
+
const url = await fastify.xStorage.getSignedUrl(key, Number(expires));
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
success: true,
|
|
117
|
+
url,
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Health check
|
|
122
|
+
fastify.get("/health", async () => {
|
|
123
|
+
return { status: "ok", timestamp: new Date().toISOString() };
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Start server
|
|
127
|
+
const start = async () => {
|
|
128
|
+
try {
|
|
129
|
+
await fastify.listen({ port: process.env.PORT || 3000, host: "0.0.0.0" });
|
|
130
|
+
} catch (err) {
|
|
131
|
+
fastify.log.error(err);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
start();
|
package/src/index.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// src/services/storage.js
|
|
2
|
+
import {
|
|
3
|
+
PutObjectCommand,
|
|
4
|
+
GetObjectCommand,
|
|
5
|
+
DeleteObjectCommand,
|
|
6
|
+
ListObjectsV2Command,
|
|
7
|
+
CopyObjectCommand,
|
|
8
|
+
HeadObjectCommand,
|
|
9
|
+
} from "@aws-sdk/client-s3";
|
|
10
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
11
|
+
import { lookup } from "mime-types";
|
|
12
|
+
import { randomBytes } from "crypto";
|
|
13
|
+
import path from "path";
|
|
14
|
+
|
|
15
|
+
export function getStorageMethods(fastify, config) {
|
|
16
|
+
const { s3Client, bucket, publicUrl, acl } = config;
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
/**
|
|
20
|
+
* Upload a file to S3-compatible storage
|
|
21
|
+
* @param {Buffer|Stream} file - File buffer or stream
|
|
22
|
+
* @param {string} filename - Original filename
|
|
23
|
+
* @param {object} options - Upload options
|
|
24
|
+
* @returns {Promise<{key: string, url: string, size: number, contentType: string}>}
|
|
25
|
+
*/
|
|
26
|
+
upload: async (file, filename, options = {}) => {
|
|
27
|
+
try {
|
|
28
|
+
const {
|
|
29
|
+
folder = "",
|
|
30
|
+
key = null,
|
|
31
|
+
contentType = null,
|
|
32
|
+
metadata = {},
|
|
33
|
+
useRandomName = true,
|
|
34
|
+
acl: fileAcl = acl, // Allow per-file ACL override
|
|
35
|
+
} = options;
|
|
36
|
+
|
|
37
|
+
// Generate file key
|
|
38
|
+
let fileKey;
|
|
39
|
+
if (key) {
|
|
40
|
+
fileKey = key;
|
|
41
|
+
} else {
|
|
42
|
+
const ext = path.extname(filename);
|
|
43
|
+
const baseName = path.basename(filename, ext);
|
|
44
|
+
const randomName = useRandomName
|
|
45
|
+
? `${baseName}-${randomBytes(8).toString("hex")}${ext}`
|
|
46
|
+
: filename;
|
|
47
|
+
fileKey = folder ? `${folder}/${randomName}` : randomName;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Determine content type
|
|
51
|
+
const mimeType = contentType || lookup(filename) || "application/octet-stream";
|
|
52
|
+
|
|
53
|
+
// Upload to S3 with per-file ACL
|
|
54
|
+
const command = new PutObjectCommand({
|
|
55
|
+
Bucket: bucket,
|
|
56
|
+
Key: fileKey,
|
|
57
|
+
Body: file,
|
|
58
|
+
ContentType: mimeType,
|
|
59
|
+
ACL: fileAcl,
|
|
60
|
+
Metadata: metadata,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await s3Client.send(command);
|
|
64
|
+
|
|
65
|
+
// Get file size
|
|
66
|
+
const fileSize = Buffer.isBuffer(file) ? file.length : null;
|
|
67
|
+
|
|
68
|
+
const result = {
|
|
69
|
+
key: fileKey,
|
|
70
|
+
url: `${publicUrl}/${fileKey}`,
|
|
71
|
+
size: fileSize,
|
|
72
|
+
contentType: mimeType,
|
|
73
|
+
bucket,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
fastify.log.info(`File uploaded: ${fileKey}`);
|
|
77
|
+
return result;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
fastify.log.error("Storage upload failed:", error);
|
|
80
|
+
throw new Error(`Failed to upload file: ${error.message}`);
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Upload multiple files
|
|
86
|
+
* @param {Array<{file: Buffer, filename: string}>} files - Array of files
|
|
87
|
+
* @param {object} options - Upload options
|
|
88
|
+
* @returns {Promise<Array>}
|
|
89
|
+
*/
|
|
90
|
+
uploadMultiple: async (files, options = {}) => {
|
|
91
|
+
try {
|
|
92
|
+
const {
|
|
93
|
+
folder = "",
|
|
94
|
+
key = null,
|
|
95
|
+
contentType = null,
|
|
96
|
+
metadata = {},
|
|
97
|
+
useRandomName = true,
|
|
98
|
+
acl: batchAcl = acl, // Allow batch ACL override
|
|
99
|
+
} = options;
|
|
100
|
+
|
|
101
|
+
const uploadPromises = files.map((fileData) => {
|
|
102
|
+
// Determine per-file ACL (file can override batch ACL)
|
|
103
|
+
const fileAcl = fileData.acl || batchAcl;
|
|
104
|
+
|
|
105
|
+
// Generate file key
|
|
106
|
+
let fileKey;
|
|
107
|
+
if (key) {
|
|
108
|
+
fileKey = key;
|
|
109
|
+
} else {
|
|
110
|
+
const ext = path.extname(fileData.filename);
|
|
111
|
+
const baseName = path.basename(fileData.filename, ext);
|
|
112
|
+
const randomName = useRandomName
|
|
113
|
+
? `${baseName}-${randomBytes(8).toString("hex")}${ext}`
|
|
114
|
+
: fileData.filename;
|
|
115
|
+
fileKey = folder ? `${folder}/${randomName}` : randomName;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Determine content type
|
|
119
|
+
const mimeType = contentType || lookup(fileData.filename) || "application/octet-stream";
|
|
120
|
+
|
|
121
|
+
// Upload to S3 with per-file ACL support
|
|
122
|
+
const command = new PutObjectCommand({
|
|
123
|
+
Bucket: bucket,
|
|
124
|
+
Key: fileKey,
|
|
125
|
+
Body: fileData.file,
|
|
126
|
+
ContentType: mimeType,
|
|
127
|
+
ACL: fileAcl,
|
|
128
|
+
Metadata: metadata,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return s3Client.send(command).then(() => {
|
|
132
|
+
const fileSize = Buffer.isBuffer(fileData.file) ? fileData.file.length : null;
|
|
133
|
+
return {
|
|
134
|
+
key: fileKey,
|
|
135
|
+
url: `${publicUrl}/${fileKey}`,
|
|
136
|
+
size: fileSize,
|
|
137
|
+
contentType: mimeType,
|
|
138
|
+
bucket,
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const results = await Promise.all(uploadPromises);
|
|
144
|
+
fastify.log.info(`${results.length} files uploaded successfully`);
|
|
145
|
+
return results;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
fastify.log.error("Multiple upload failed:", error);
|
|
148
|
+
throw new Error(`Failed to upload multiple files: ${error.message}`);
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Download a file from storage
|
|
154
|
+
* @param {string} key - File key
|
|
155
|
+
* @returns {Promise<Buffer>}
|
|
156
|
+
*/
|
|
157
|
+
download: async (key) => {
|
|
158
|
+
try {
|
|
159
|
+
const command = new GetObjectCommand({
|
|
160
|
+
Bucket: bucket,
|
|
161
|
+
Key: key,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const response = await s3Client.send(command);
|
|
165
|
+
const chunks = [];
|
|
166
|
+
|
|
167
|
+
for await (const chunk of response.Body) {
|
|
168
|
+
chunks.push(chunk);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return Buffer.concat(chunks);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
fastify.log.error("Storage download failed:", error);
|
|
174
|
+
throw new Error(`Failed to download file: ${error.message}`);
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Delete a file from storage
|
|
180
|
+
* @param {string} key - File key
|
|
181
|
+
* @returns {Promise<boolean>}
|
|
182
|
+
*/
|
|
183
|
+
delete: async (key) => {
|
|
184
|
+
try {
|
|
185
|
+
const command = new DeleteObjectCommand({
|
|
186
|
+
Bucket: bucket,
|
|
187
|
+
Key: key,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await s3Client.send(command);
|
|
191
|
+
fastify.log.info(`File deleted: ${key}`);
|
|
192
|
+
return true;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
fastify.log.error("Storage delete failed:", error);
|
|
195
|
+
throw new Error(`Failed to delete file: ${error.message}`);
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Delete multiple files
|
|
201
|
+
* @param {Array<string>} keys - Array of file keys
|
|
202
|
+
* @returns {Promise<boolean>}
|
|
203
|
+
*/
|
|
204
|
+
deleteMultiple: async (keys) => {
|
|
205
|
+
try {
|
|
206
|
+
const deletePromises = keys.map((key) => fastify.storage.delete(key));
|
|
207
|
+
await Promise.all(deletePromises);
|
|
208
|
+
return true;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
fastify.log.error("Multiple delete failed:", error);
|
|
211
|
+
throw new Error(`Failed to delete multiple files: ${error.message}`);
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Copy a file within storage
|
|
217
|
+
* @param {string} sourceKey - Source file key
|
|
218
|
+
* @param {string} destinationKey - Destination file key
|
|
219
|
+
* @returns {Promise<{key: string, url: string}>}
|
|
220
|
+
*/
|
|
221
|
+
copy: async (sourceKey, destinationKey) => {
|
|
222
|
+
try {
|
|
223
|
+
const command = new CopyObjectCommand({
|
|
224
|
+
Bucket: bucket,
|
|
225
|
+
CopySource: `${bucket}/${sourceKey}`,
|
|
226
|
+
Key: destinationKey,
|
|
227
|
+
ACL: acl,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await s3Client.send(command);
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
key: destinationKey,
|
|
234
|
+
url: `${publicUrl}/${destinationKey}`,
|
|
235
|
+
};
|
|
236
|
+
} catch (error) {
|
|
237
|
+
fastify.log.error("Storage copy failed:", error);
|
|
238
|
+
throw new Error(`Failed to copy file: ${error.message}`);
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Check if a file exists
|
|
244
|
+
* @param {string} key - File key
|
|
245
|
+
* @returns {Promise<boolean>}
|
|
246
|
+
*/
|
|
247
|
+
exists: async (key) => {
|
|
248
|
+
try {
|
|
249
|
+
const command = new HeadObjectCommand({
|
|
250
|
+
Bucket: bucket,
|
|
251
|
+
Key: key,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
await s3Client.send(command);
|
|
255
|
+
return true;
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (error.name === "NotFound") {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Get file metadata
|
|
266
|
+
* @param {string} key - File key
|
|
267
|
+
* @returns {Promise<object>}
|
|
268
|
+
*/
|
|
269
|
+
getMetadata: async (key) => {
|
|
270
|
+
try {
|
|
271
|
+
const command = new HeadObjectCommand({
|
|
272
|
+
Bucket: bucket,
|
|
273
|
+
Key: key,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const response = await s3Client.send(command);
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
contentType: response.ContentType,
|
|
280
|
+
contentLength: response.ContentLength,
|
|
281
|
+
lastModified: response.LastModified,
|
|
282
|
+
etag: response.ETag,
|
|
283
|
+
metadata: response.Metadata,
|
|
284
|
+
};
|
|
285
|
+
} catch (error) {
|
|
286
|
+
fastify.log.error("Get metadata failed:", error);
|
|
287
|
+
throw new Error(`Failed to get file metadata: ${error.message}`);
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* List files in a folder
|
|
293
|
+
* @param {string} prefix - Folder prefix
|
|
294
|
+
* @param {number} maxKeys - Maximum number of keys to return
|
|
295
|
+
* @returns {Promise<Array>}
|
|
296
|
+
*/
|
|
297
|
+
list: async (prefix = "", maxKeys = 1000) => {
|
|
298
|
+
try {
|
|
299
|
+
const command = new ListObjectsV2Command({
|
|
300
|
+
Bucket: bucket,
|
|
301
|
+
Prefix: prefix,
|
|
302
|
+
MaxKeys: maxKeys,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const response = await s3Client.send(command);
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
response.Contents?.map((item) => ({
|
|
309
|
+
key: item.Key,
|
|
310
|
+
url: `${publicUrl}/${item.Key}`,
|
|
311
|
+
size: item.Size,
|
|
312
|
+
lastModified: item.LastModified,
|
|
313
|
+
etag: item.ETag,
|
|
314
|
+
})) || []
|
|
315
|
+
);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
fastify.log.error("Storage list failed:", error);
|
|
318
|
+
throw new Error(`Failed to list files: ${error.message}`);
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Generate a pre-signed URL for temporary access
|
|
324
|
+
* @param {string} key - File key
|
|
325
|
+
* @param {number} expiresIn - Expiration time in seconds (default: 1 hour)
|
|
326
|
+
* @returns {Promise<string>}
|
|
327
|
+
*/
|
|
328
|
+
getSignedUrl: async (key, expiresIn = 3600) => {
|
|
329
|
+
try {
|
|
330
|
+
const command = new GetObjectCommand({
|
|
331
|
+
Bucket: bucket,
|
|
332
|
+
Key: key,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const url = await getSignedUrl(s3Client, command, { expiresIn });
|
|
336
|
+
return url;
|
|
337
|
+
} catch (error) {
|
|
338
|
+
fastify.log.error("Generate signed URL failed:", error);
|
|
339
|
+
throw new Error(`Failed to generate signed URL: ${error.message}`);
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Get public URL for a file
|
|
345
|
+
* @param {string} key - File key
|
|
346
|
+
* @returns {string}
|
|
347
|
+
*/
|
|
348
|
+
getPublicUrl: (key) => {
|
|
349
|
+
return `${publicUrl}/${key}`;
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|