@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.
@@ -0,0 +1,238 @@
1
+ // src/utils/helpers.js
2
+
3
+ /**
4
+ * Format file size in human-readable format
5
+ * @param {number} bytes - File size in bytes
6
+ * @returns {string}
7
+ */
8
+ export function formatFileSize(bytes) {
9
+ if (bytes === 0) return "0 Bytes";
10
+
11
+ const k = 1024;
12
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
13
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
14
+
15
+ return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
16
+ }
17
+
18
+ /**
19
+ * Generate a unique filename with timestamp
20
+ * @param {string} originalName - Original filename
21
+ * @returns {string}
22
+ */
23
+ export function generateUniqueFilename(originalName) {
24
+ const timestamp = Date.now();
25
+ const ext = originalName.substring(originalName.lastIndexOf("."));
26
+ const name = originalName.substring(0, originalName.lastIndexOf("."));
27
+ return `${name}-${timestamp}${ext}`;
28
+ }
29
+
30
+ /**
31
+ * Validate file type
32
+ * @param {string} filename - Filename
33
+ * @param {Array<string>} allowedTypes - Allowed file extensions
34
+ * @returns {boolean}
35
+ */
36
+ export function isValidFileType(filename, allowedTypes = []) {
37
+ if (allowedTypes.length === 0) return true;
38
+
39
+ const ext = filename.substring(filename.lastIndexOf(".") + 1).toLowerCase();
40
+ return allowedTypes.includes(ext);
41
+ }
42
+
43
+ /**
44
+ * Check if file is an image
45
+ * @param {string} filename - Filename
46
+ * @returns {boolean}
47
+ */
48
+ export function isImage(filename) {
49
+ const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "avif"];
50
+ return isValidFileType(filename, imageExtensions);
51
+ }
52
+
53
+ /**
54
+ * Check if file is a PDF
55
+ * @param {string} filename - Filename
56
+ * @returns {boolean}
57
+ */
58
+ export function isPdf(filename) {
59
+ return filename.toLowerCase().endsWith(".pdf");
60
+ }
61
+
62
+ /**
63
+ * Check if file is a video
64
+ * @param {string} filename - Filename
65
+ * @returns {boolean}
66
+ */
67
+ export function isVideo(filename) {
68
+ const videoExtensions = ["mp4", "avi", "mov", "wmv", "flv", "webm", "mkv"];
69
+ return isValidFileType(filename, videoExtensions);
70
+ }
71
+
72
+ /**
73
+ * Sanitize filename to remove unsafe characters
74
+ * @param {string} filename - Original filename
75
+ * @returns {string}
76
+ */
77
+ export function sanitizeFilename(filename) {
78
+ return filename
79
+ .replace(/[^a-zA-Z0-9._-]/g, "_") // Replace unsafe chars with underscore
80
+ .replace(/_{2,}/g, "_") // Replace multiple underscores with single
81
+ .toLowerCase();
82
+ }
83
+
84
+ /**
85
+ * Extract file extension
86
+ * @param {string} filename - Filename
87
+ * @returns {string}
88
+ */
89
+ export function getFileExtension(filename) {
90
+ return filename.substring(filename.lastIndexOf(".") + 1).toLowerCase();
91
+ }
92
+
93
+ /**
94
+ * Get filename without extension
95
+ * @param {string} filename - Filename
96
+ * @returns {string}
97
+ */
98
+ export function getFilenameWithoutExtension(filename) {
99
+ return filename.substring(0, filename.lastIndexOf("."));
100
+ }
101
+
102
+ /**
103
+ * Build storage key with folder structure
104
+ * @param {string} filename - Filename
105
+ * @param {object} options - Options
106
+ * @returns {string}
107
+ */
108
+ export function buildStorageKey(filename, options = {}) {
109
+ const { folder = "", prefix = "", suffix = "", timestamp = false } = options;
110
+
111
+ let key = filename;
112
+
113
+ if (timestamp) {
114
+ const ext = getFileExtension(filename);
115
+ const name = getFilenameWithoutExtension(filename);
116
+ key = `${name}-${Date.now()}.${ext}`;
117
+ }
118
+
119
+ if (prefix) {
120
+ key = `${prefix}-${key}`;
121
+ }
122
+
123
+ if (suffix) {
124
+ const ext = getFileExtension(key);
125
+ const name = getFilenameWithoutExtension(key);
126
+ key = `${name}-${suffix}.${ext}`;
127
+ }
128
+
129
+ if (folder) {
130
+ key = `${folder}/${key}`;
131
+ }
132
+
133
+ return key;
134
+ }
135
+
136
+ /**
137
+ * Calculate image aspect ratio
138
+ * @param {number} width - Image width
139
+ * @param {number} height - Image height
140
+ * @returns {string}
141
+ */
142
+ export function getAspectRatio(width, height) {
143
+ const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b));
144
+ const divisor = gcd(width, height);
145
+ return `${width / divisor}:${height / divisor}`;
146
+ }
147
+
148
+ /**
149
+ * Calculate dimensions to fit within max dimensions while preserving aspect ratio
150
+ * @param {number} width - Original width
151
+ * @param {number} height - Original height
152
+ * @param {number} maxWidth - Maximum width
153
+ * @param {number} maxHeight - Maximum height
154
+ * @returns {{width: number, height: number}}
155
+ */
156
+ export function calculateFitDimensions(width, height, maxWidth, maxHeight) {
157
+ const aspectRatio = width / height;
158
+
159
+ let newWidth = width;
160
+ let newHeight = height;
161
+
162
+ if (width > maxWidth) {
163
+ newWidth = maxWidth;
164
+ newHeight = newWidth / aspectRatio;
165
+ }
166
+
167
+ if (newHeight > maxHeight) {
168
+ newHeight = maxHeight;
169
+ newWidth = newHeight * aspectRatio;
170
+ }
171
+
172
+ return {
173
+ width: Math.round(newWidth),
174
+ height: Math.round(newHeight),
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Parse S3 URL to get key
180
+ * @param {string} url - S3 URL
181
+ * @param {string} publicUrl - Public URL base
182
+ * @returns {string}
183
+ */
184
+ export function getKeyFromUrl(url, publicUrl) {
185
+ return url.replace(publicUrl + "/", "");
186
+ }
187
+
188
+ /**
189
+ * Group files by folder
190
+ * @param {Array<object>} files - Array of file objects with key property
191
+ * @returns {object}
192
+ */
193
+ export function groupFilesByFolder(files) {
194
+ return files.reduce((acc, file) => {
195
+ const folder = file.key.includes("/")
196
+ ? file.key.substring(0, file.key.lastIndexOf("/"))
197
+ : "root";
198
+
199
+ if (!acc[folder]) {
200
+ acc[folder] = [];
201
+ }
202
+
203
+ acc[folder].push(file);
204
+ return acc;
205
+ }, {});
206
+ }
207
+
208
+ /**
209
+ * Validate image dimensions
210
+ * @param {number} width - Image width
211
+ * @param {number} height - Image height
212
+ * @param {object} constraints - Dimension constraints
213
+ * @returns {boolean}
214
+ */
215
+ export function isValidDimensions(width, height, constraints = {}) {
216
+ const { minWidth = 0, minHeight = 0, maxWidth = Infinity, maxHeight = Infinity } = constraints;
217
+
218
+ return width >= minWidth && width <= maxWidth && height >= minHeight && height <= maxHeight;
219
+ }
220
+
221
+ /**
222
+ * Generate responsive image sizes
223
+ * @param {number} originalWidth - Original image width
224
+ * @param {number} originalHeight - Original image height
225
+ * @returns {Array<object>}
226
+ */
227
+ export function generateResponsiveSizes(originalWidth, originalHeight) {
228
+ const breakpoints = [320, 640, 768, 1024, 1280, 1536, 1920];
229
+ const aspectRatio = originalWidth / originalHeight;
230
+
231
+ return breakpoints
232
+ .filter((width) => width <= originalWidth)
233
+ .map((width) => ({
234
+ width,
235
+ height: Math.round(width / aspectRatio),
236
+ name: `w${width}`,
237
+ }));
238
+ }
@@ -0,0 +1,86 @@
1
+ // src/xStorage.js
2
+ import fp from "fastify-plugin";
3
+ import { S3Client } from "@aws-sdk/client-s3";
4
+ import { getStorageMethods } from "./services/storage.js";
5
+
6
+ /**
7
+ * xStorage Plugin for Fastify
8
+ * Generic S3-compatible file storage library with simple, intuitive API
9
+ *
10
+ * Provides methods for:
11
+ * - Upload, download, delete, list, copy operations
12
+ * - Signed URLs for secure temporary access (default: private)
13
+ * - Public URLs for public files (optional)
14
+ * - Metadata retrieval and file operations
15
+ * - Concurrent operations (batch uploads/deletes)
16
+ *
17
+ * Supports: AWS S3, Cloudflare R2, Digital Ocean Spaces, any S3-compatible provider
18
+ *
19
+ * Usage:
20
+ * await fastify.xStorage.upload(buffer, 'filename.txt', { folder: 'docs' })
21
+ * const url = await fastify.xStorage.getSignedUrl('docs/filename.txt', 3600)
22
+ * await fastify.xStorage.delete('docs/filename.txt')
23
+ *
24
+ * For specialized processing:
25
+ * - Use @xenterprises/fastify-ximagepipeline for image processing
26
+ * - Use your own PDF library or service for PDF operations
27
+ */
28
+ async function xStoragePlugin(fastify, options) {
29
+ const {
30
+ endpoint,
31
+ region = "us-east-1",
32
+ accessKeyId,
33
+ secretAccessKey,
34
+ bucket,
35
+ publicUrl,
36
+ forcePathStyle = true, // Required for Digital Ocean Spaces and some S3-compatible services
37
+ acl = "private", // Default to private, use signed URLs for access
38
+ } = options;
39
+
40
+ // Validate required options
41
+ if (!endpoint && !region) {
42
+ throw new Error("Either endpoint (for S3-compatible) or region (for AWS S3) is required");
43
+ }
44
+ if (!accessKeyId || !secretAccessKey) {
45
+ throw new Error("accessKeyId and secretAccessKey are required");
46
+ }
47
+ if (!bucket) {
48
+ throw new Error("bucket name is required");
49
+ }
50
+ if (!publicUrl) {
51
+ throw new Error("publicUrl is required (e.g., https://your-bucket.nyc3.digitaloceanspaces.com)");
52
+ }
53
+
54
+ console.info("\n 📦 Starting xStorage...\n");
55
+
56
+ // Initialize S3 client
57
+ const s3Client = new S3Client({
58
+ endpoint,
59
+ region,
60
+ credentials: {
61
+ accessKeyId,
62
+ secretAccessKey,
63
+ },
64
+ forcePathStyle,
65
+ });
66
+
67
+ // Store configuration
68
+ const config = {
69
+ s3Client,
70
+ bucket,
71
+ publicUrl: publicUrl.endsWith("/") ? publicUrl.slice(0, -1) : publicUrl,
72
+ acl,
73
+ };
74
+
75
+ // Get storage methods
76
+ const storageMethods = getStorageMethods(fastify, config);
77
+
78
+ // Decorate Fastify with xStorage namespace
79
+ fastify.decorate("xStorage", storageMethods);
80
+
81
+ console.info("\n 📦 xStorage Ready!\n");
82
+ }
83
+
84
+ export default fp(xStoragePlugin, {
85
+ name: "xStorage",
86
+ });
@@ -0,0 +1,257 @@
1
+ // test/storage.test.js
2
+ import { test } from "node:test";
3
+ import assert from "node:assert";
4
+ import Fastify from "fastify";
5
+ import xStorage from "../src/xStorage.js";
6
+ import { helpers } from "../src/index.js";
7
+
8
+ // Mock storage configuration
9
+ const mockStorageConfig = {
10
+ endpoint: process.env.STORAGE_ENDPOINT || "https://nyc3.digitaloceanspaces.com",
11
+ region: process.env.STORAGE_REGION || "nyc3",
12
+ accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || "mock_access_key",
13
+ secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || "mock_secret_key",
14
+ bucket: process.env.STORAGE_BUCKET || "test-bucket",
15
+ publicUrl: process.env.STORAGE_PUBLIC_URL || "https://test-bucket.nyc3.digitaloceanspaces.com",
16
+ };
17
+
18
+ test("Storage plugin - registers successfully", async () => {
19
+ const fastify = Fastify({ logger: false });
20
+
21
+ try {
22
+ await fastify.register(xStorage, mockStorageConfig);
23
+ assert.ok(true, "Plugin registered successfully");
24
+ } finally {
25
+ await fastify.close();
26
+ }
27
+ });
28
+
29
+ test("Helper functions - file size formatting", () => {
30
+ assert.equal(helpers.formatFileSize(0), "0 Bytes");
31
+ assert.equal(helpers.formatFileSize(1024), "1 KB");
32
+ assert.equal(helpers.formatFileSize(1048576), "1 MB");
33
+ });
34
+
35
+ test("Helper functions - file type detection", () => {
36
+ // isImage
37
+ assert.equal(helpers.isImage("photo.jpg"), true);
38
+ assert.equal(helpers.isImage("photo.png"), true);
39
+ assert.equal(helpers.isImage("photo.webp"), true);
40
+ assert.equal(helpers.isImage("document.pdf"), false);
41
+
42
+ // isPdf
43
+ assert.equal(helpers.isPdf("document.pdf"), true);
44
+ assert.equal(helpers.isPdf("photo.jpg"), false);
45
+
46
+ // isVideo
47
+ assert.equal(helpers.isVideo("movie.mp4"), true);
48
+ assert.equal(helpers.isVideo("photo.jpg"), false);
49
+ });
50
+
51
+ test("Helper functions - filename sanitization", () => {
52
+ // Test sanitization - the function replaces special chars with underscore
53
+ const result1 = helpers.sanitizeFilename("My File (2024).jpg");
54
+ assert.ok(result1.includes("my_file"), "should contain lowercase filename");
55
+ assert.ok(result1.endsWith(".jpg"), "should preserve extension");
56
+
57
+ const result2 = helpers.sanitizeFilename("Test@#$%.txt");
58
+ assert.ok(result2.startsWith("test"), "should be lowercase");
59
+ assert.ok(result2.endsWith(".txt"), "should preserve extension");
60
+ });
61
+
62
+ test("Helper functions - file extension extraction", () => {
63
+ assert.equal(helpers.getFileExtension("photo.jpg"), "jpg");
64
+ assert.equal(helpers.getFileExtension("document.pdf"), "pdf");
65
+ });
66
+
67
+ test("Helper functions - dimension calculations", () => {
68
+ const dimensions = helpers.calculateFitDimensions(4000, 3000, 1920, 1080);
69
+ assert.equal(dimensions.width, 1440);
70
+ assert.equal(dimensions.height, 1080);
71
+ });
72
+
73
+ test("Helper functions - aspect ratio calculation", () => {
74
+ assert.equal(helpers.getAspectRatio(1920, 1080), "16:9");
75
+ assert.equal(helpers.getAspectRatio(1000, 1000), "1:1");
76
+ });
77
+
78
+ test("Storage plugin - configuration with custom public URL", async () => {
79
+ const fastify = Fastify({ logger: false });
80
+ const customConfig = {
81
+ ...mockStorageConfig,
82
+ publicUrl: "https://cdn.example.com",
83
+ };
84
+
85
+ try {
86
+ await fastify.register(xStorage, customConfig);
87
+ assert.ok(true, "Plugin registered with custom public URL");
88
+ } finally {
89
+ await fastify.close();
90
+ }
91
+ });
92
+
93
+ // Generic File Storage Operations Tests
94
+ test("Generic file storage - upload operations available", async () => {
95
+ const fastify = Fastify({ logger: false });
96
+
97
+ try {
98
+ await fastify.register(xStorage, mockStorageConfig);
99
+
100
+ // Verify core storage methods are available
101
+ assert.ok(fastify.xStorage, "xStorage namespace available");
102
+ assert.ok(fastify.xStorage.upload, "Upload method exists");
103
+ assert.ok(fastify.xStorage.download, "Download method exists");
104
+ assert.ok(fastify.xStorage.delete, "Delete method exists");
105
+ assert.ok(fastify.xStorage.list, "List method exists");
106
+ } finally {
107
+ await fastify.close();
108
+ }
109
+ });
110
+
111
+ test("Generic file storage - file operations", async () => {
112
+ const fastify = Fastify({ logger: false });
113
+
114
+ try {
115
+ await fastify.register(xStorage, mockStorageConfig);
116
+
117
+ // Verify file operations are available
118
+ assert.ok(fastify.xStorage.copy, "Copy method exists");
119
+ assert.ok(fastify.xStorage.exists, "Exists check method exists");
120
+ assert.ok(fastify.xStorage.getMetadata, "Get metadata method exists");
121
+ } finally {
122
+ await fastify.close();
123
+ }
124
+ });
125
+
126
+ test("Generic file storage - URL operations", async () => {
127
+ const fastify = Fastify({ logger: false });
128
+
129
+ try {
130
+ await fastify.register(xStorage, mockStorageConfig);
131
+
132
+ // Verify URL operations
133
+ assert.ok(fastify.xStorage.getSignedUrl, "Signed URL generation available");
134
+ assert.ok(fastify.xStorage.getPublicUrl, "Public URL generation available");
135
+ } finally {
136
+ await fastify.close();
137
+ }
138
+ });
139
+
140
+ test("Error handling - invalid credentials handling", async () => {
141
+ const fastify = Fastify({ logger: false });
142
+ const invalidConfig = {
143
+ ...mockStorageConfig,
144
+ accessKeyId: "",
145
+ secretAccessKey: "",
146
+ };
147
+
148
+ try {
149
+ await assert.rejects(
150
+ async () => {
151
+ await fastify.register(xStorage, invalidConfig);
152
+ },
153
+ /accessKeyId and secretAccessKey are required/
154
+ );
155
+ } finally {
156
+ try {
157
+ await fastify.close();
158
+ } catch {
159
+ // Ignore
160
+ }
161
+ }
162
+ });
163
+
164
+ test("Batch operations - concurrent uploads", async () => {
165
+ const fastify = Fastify({ logger: false });
166
+
167
+ try {
168
+ await fastify.register(xStorage, mockStorageConfig);
169
+
170
+ // Verify concurrent upload support
171
+ assert.ok(fastify.xStorage.uploadMultiple, "Concurrent uploads supported");
172
+ } finally {
173
+ await fastify.close();
174
+ }
175
+ });
176
+
177
+ test("Batch operations - concurrent deletes", async () => {
178
+ const fastify = Fastify({ logger: false });
179
+
180
+ try {
181
+ await fastify.register(xStorage, mockStorageConfig);
182
+
183
+ // Verify concurrent delete support
184
+ assert.ok(fastify.xStorage.deleteMultiple, "Concurrent deletes supported");
185
+ } finally {
186
+ await fastify.close();
187
+ }
188
+ });
189
+
190
+ test("Edge cases - handle special characters in filename", async () => {
191
+ const fastify = Fastify({ logger: false });
192
+
193
+ try {
194
+ await fastify.register(xStorage, mockStorageConfig);
195
+
196
+ // Special character handling in filenames
197
+ const result = helpers.sanitizeFilename("Test@#$%^&*()_+=-[]{}|;:,.<>?/~`.jpg");
198
+ assert.ok(result.endsWith(".jpg"), "Special characters handled");
199
+ } finally {
200
+ await fastify.close();
201
+ }
202
+ });
203
+
204
+ test("Configuration - supports AWS S3", async () => {
205
+ const fastify = Fastify({ logger: false });
206
+ const awsConfig = {
207
+ region: "us-east-1",
208
+ accessKeyId: "mock_key",
209
+ secretAccessKey: "mock_secret",
210
+ bucket: "test-bucket",
211
+ publicUrl: "https://test-bucket.s3.amazonaws.com",
212
+ };
213
+
214
+ try {
215
+ await fastify.register(xStorage, awsConfig);
216
+ assert.ok(true, "Plugin supports AWS S3 configuration");
217
+ } finally {
218
+ await fastify.close();
219
+ }
220
+ });
221
+
222
+ test("Configuration - supports Cloudflare R2", async () => {
223
+ const fastify = Fastify({ logger: false });
224
+ const r2Config = {
225
+ endpoint: "https://account-id.r2.cloudflarestorage.com",
226
+ accessKeyId: "mock_key",
227
+ secretAccessKey: "mock_secret",
228
+ bucket: "test-bucket",
229
+ publicUrl: "https://test-bucket.example.com",
230
+ };
231
+
232
+ try {
233
+ await fastify.register(xStorage, r2Config);
234
+ assert.ok(true, "Plugin supports Cloudflare R2 configuration");
235
+ } finally {
236
+ await fastify.close();
237
+ }
238
+ });
239
+
240
+ test("Configuration - supports Digital Ocean Spaces", async () => {
241
+ const fastify = Fastify({ logger: false });
242
+ const dosConfig = {
243
+ endpoint: "https://nyc3.digitaloceanspaces.com",
244
+ region: "nyc3",
245
+ accessKeyId: "mock_key",
246
+ secretAccessKey: "mock_secret",
247
+ bucket: "test-bucket",
248
+ publicUrl: "https://test-bucket.nyc3.digitaloceanspaces.com",
249
+ };
250
+
251
+ try {
252
+ await fastify.register(xStorage, dosConfig);
253
+ assert.ok(true, "Plugin supports Digital Ocean Spaces configuration");
254
+ } finally {
255
+ await fastify.close();
256
+ }
257
+ });