cynx-lib 1.3.45 → 1.3.47
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/gen.js +77 -77
- package/package.json +1 -1
- package/ui/components/Layout.jsx +831 -831
- package/utils/index.js +20 -20
- package/utils/utils/db.js +946 -946
- package/utils/utils/duration.js +139 -139
- package/utils/utils/oidc.js +214 -214
- package/utils/utils/realtime.js +1040 -1040
- package/utils/utils/session.js +58 -58
- package/utils/utils/storage.js +393 -393
- package/utils/utils/storage_manager.cjs +14 -5
- package/utils/utils/storage_manager.js +1355 -1345
- package/utils/utils/ui.js +111 -111
package/utils/utils/storage.js
CHANGED
|
@@ -1,393 +1,393 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* cynx/utils — Storage Server Client & Connector (storage.js)
|
|
3
|
-
*
|
|
4
|
-
* Universal client SDK to interact with cynx-storage-server:
|
|
5
|
-
* - Upload files (Browser File/Blob or Node.js Buffer/Stream/Blob)
|
|
6
|
-
* - Query file metadata & entity attachments
|
|
7
|
-
* - Delete files or whole entity file groups
|
|
8
|
-
* - Generate public CDN URLs and stream content
|
|
9
|
-
*
|
|
10
|
-
* Works in Browser, Node.js (18+), Next.js / SSR, and Web Workers.
|
|
11
|
-
* Zero external dependencies.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
// ─── UTILITIES & HELPERS ────────────────────────────────────────────────────────
|
|
15
|
-
|
|
16
|
-
const isNode = typeof process !== 'undefined' && Boolean(process.versions?.node);
|
|
17
|
-
|
|
18
|
-
function getDefaultBaseUrl() {
|
|
19
|
-
if (isNode && process.env?.STORAGE_SERVER_URL) {
|
|
20
|
-
return process.env.STORAGE_SERVER_URL;
|
|
21
|
-
}
|
|
22
|
-
if (typeof window !== 'undefined' && window.location?.origin) {
|
|
23
|
-
return window.location.origin;
|
|
24
|
-
}
|
|
25
|
-
return '';
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function getDefaultApiKey() {
|
|
29
|
-
if (isNode && (process.env?.STORAGE_SERVER_API_KEY || process.env?.STORAGE_API_KEY)) {
|
|
30
|
-
return process.env.STORAGE_SERVER_API_KEY || process.env.STORAGE_API_KEY;
|
|
31
|
-
}
|
|
32
|
-
return '';
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Format raw byte numbers into human-readable strings.
|
|
37
|
-
* @param {number|string} bytes
|
|
38
|
-
* @returns {string} e.g. "12.5 MB"
|
|
39
|
-
*/
|
|
40
|
-
export function formatBytes(bytes) {
|
|
41
|
-
const num = Number(bytes) || 0;
|
|
42
|
-
if (num === 0) return '0 B';
|
|
43
|
-
const k = 1024;
|
|
44
|
-
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
45
|
-
const i = Math.floor(Math.log(num) / Math.log(k));
|
|
46
|
-
const val = (num / Math.pow(k, i)).toFixed(2);
|
|
47
|
-
return `${val} ${sizes[i]}`;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Categorize mimeType and filename into categories.
|
|
52
|
-
* @param {string} [mimeType='']
|
|
53
|
-
* @param {string} [filename='']
|
|
54
|
-
* @returns {'image'|'pdf'|'video'|'audio'|'document'|'archive'|'other'}
|
|
55
|
-
*/
|
|
56
|
-
export function getFileTypeCategory(mimeType = '', filename = '') {
|
|
57
|
-
const mt = (mimeType || '').toLowerCase();
|
|
58
|
-
const fn = (filename || '').toLowerCase();
|
|
59
|
-
|
|
60
|
-
if (mt.startsWith('image/') || /\.(jpg|jpeg|png|gif|webp|bmp|svg|avif|ico)$/i.test(fn)) {
|
|
61
|
-
return 'image';
|
|
62
|
-
}
|
|
63
|
-
if (mt === 'application/pdf' || /\.pdf$/i.test(fn)) {
|
|
64
|
-
return 'pdf';
|
|
65
|
-
}
|
|
66
|
-
if (mt.startsWith('video/') || /\.(mp4|webm|mkv|mov|avi|flv|wmv|m4v)$/i.test(fn)) {
|
|
67
|
-
return 'video';
|
|
68
|
-
}
|
|
69
|
-
if (mt.startsWith('audio/') || /\.(mp3|wav|ogg|m4a|aac|flac)$/i.test(fn)) {
|
|
70
|
-
return 'audio';
|
|
71
|
-
}
|
|
72
|
-
if (
|
|
73
|
-
mt.includes('word') ||
|
|
74
|
-
mt.includes('excel') ||
|
|
75
|
-
mt.includes('spreadsheet') ||
|
|
76
|
-
mt.includes('presentation') ||
|
|
77
|
-
mt.includes('csv') ||
|
|
78
|
-
/\.(doc|docx|xls|xlsx|csv|ppt|pptx|txt|rtf|md)$/i.test(fn)
|
|
79
|
-
) {
|
|
80
|
-
return 'document';
|
|
81
|
-
}
|
|
82
|
-
if (mt.includes('zip') || mt.includes('tar') || mt.includes('compressed') || /\.(zip|tar|gz|7z|rar)$/i.test(fn)) {
|
|
83
|
-
return 'archive';
|
|
84
|
-
}
|
|
85
|
-
return 'other';
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Build a public CDN URL for a given file ID.
|
|
90
|
-
* @param {string} fileId e.g. "sf_abc123"
|
|
91
|
-
* @param {string} [filename] Optional filename suffix for cleaner download names
|
|
92
|
-
* @param {string} [baseUrl] Storage server base URL
|
|
93
|
-
* @returns {string} CDN URL
|
|
94
|
-
*/
|
|
95
|
-
export function getCdnUrl(fileId, filename = '', baseUrl = '') {
|
|
96
|
-
if (!fileId) return '';
|
|
97
|
-
const base = (baseUrl || getDefaultBaseUrl() || '').replace(/\/+$/, '');
|
|
98
|
-
const cleanId = String(fileId).trim();
|
|
99
|
-
const suffix = filename ? `/${encodeURIComponent(filename)}` : '';
|
|
100
|
-
return `${base}/cdn/${encodeURIComponent(cleanId)}${suffix}`;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// ─── STORAGE CLIENT CLASS ───────────────────────────────────────────────────────
|
|
104
|
-
|
|
105
|
-
export class StorageClient {
|
|
106
|
-
/**
|
|
107
|
-
* @param {object} [options={}]
|
|
108
|
-
* @param {string} [options.baseUrl] Base URL of the storage server (e.g. "https://storage.domain.com" or "http://localhost:5000")
|
|
109
|
-
* @param {string} [options.apiKey] API Key with upload/read/delete permissions
|
|
110
|
-
* @param {number} [options.timeout=30000] Request timeout in milliseconds
|
|
111
|
-
* @param {Function} [options.fetch] Custom fetch implementation (optional)
|
|
112
|
-
*/
|
|
113
|
-
constructor(options = {}) {
|
|
114
|
-
this.baseUrl = (options.baseUrl || getDefaultBaseUrl() || '').replace(/\/+$/, '');
|
|
115
|
-
this.apiKey = options.apiKey || getDefaultApiKey() || '';
|
|
116
|
-
this.timeout = options.timeout || 30000;
|
|
117
|
-
this.fetchFn = options.fetch || (typeof globalThis !== 'undefined' ? globalThis.fetch?.bind(globalThis) : null);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Update configuration options.
|
|
122
|
-
* @param {object} options
|
|
123
|
-
*/
|
|
124
|
-
configure(options = {}) {
|
|
125
|
-
if (options.baseUrl !== undefined) this.baseUrl = String(options.baseUrl || '').replace(/\/+$/, '');
|
|
126
|
-
if (options.apiKey !== undefined) this.apiKey = String(options.apiKey || '');
|
|
127
|
-
if (options.timeout !== undefined) this.timeout = Number(options.timeout) || 30000;
|
|
128
|
-
if (options.fetch !== undefined) this.fetchFn = options.fetch;
|
|
129
|
-
return this;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Internal HTTP request helper.
|
|
134
|
-
* @private
|
|
135
|
-
*/
|
|
136
|
-
async _request(path, { method = 'GET', headers = {}, body, apiKey, signal, timeout } = {}) {
|
|
137
|
-
if (!this.fetchFn) {
|
|
138
|
-
throw new Error('[cynx/storage] No fetch implementation available in current environment.');
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const url = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
|
142
|
-
const key = apiKey !== undefined ? apiKey : this.apiKey;
|
|
143
|
-
const reqHeaders = { ...headers };
|
|
144
|
-
|
|
145
|
-
if (key) {
|
|
146
|
-
reqHeaders['x-api-key'] = key;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const abortCtrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
150
|
-
const reqTimeout = timeout || this.timeout;
|
|
151
|
-
let timer = null;
|
|
152
|
-
|
|
153
|
-
if (abortCtrl && reqTimeout > 0) {
|
|
154
|
-
timer = setTimeout(() => abortCtrl.abort(new Error(`Storage request timeout after ${reqTimeout}ms`)), reqTimeout);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const activeSignal = signal || abortCtrl?.signal;
|
|
158
|
-
|
|
159
|
-
try {
|
|
160
|
-
const response = await this.fetchFn(url, {
|
|
161
|
-
method,
|
|
162
|
-
headers: reqHeaders,
|
|
163
|
-
body,
|
|
164
|
-
signal: activeSignal,
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
if (timer) clearTimeout(timer);
|
|
168
|
-
|
|
169
|
-
if (!response.ok) {
|
|
170
|
-
let errData = null;
|
|
171
|
-
try {
|
|
172
|
-
errData = await response.json();
|
|
173
|
-
} catch {
|
|
174
|
-
errData = { error: response.statusText || `HTTP ${response.status}` };
|
|
175
|
-
}
|
|
176
|
-
const error = new Error(errData.error || errData.message || `Storage Error: ${response.status}`);
|
|
177
|
-
error.status = response.status;
|
|
178
|
-
error.response = errData;
|
|
179
|
-
throw error;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return await response.json();
|
|
183
|
-
} catch (err) {
|
|
184
|
-
if (timer) clearTimeout(timer);
|
|
185
|
-
throw err;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Upload one or multiple files to the storage server.
|
|
191
|
-
*
|
|
192
|
-
* @param {object} params
|
|
193
|
-
* @param {File|Blob|Buffer|ArrayBuffer|Array<File|Blob|Buffer|object>} [params.files] File or array of files to upload
|
|
194
|
-
* @param {File|Blob|Buffer|ArrayBuffer} [params.file] Single file alias
|
|
195
|
-
* @param {string} [params.folder] Target subfolder in cloud storage
|
|
196
|
-
* @param {string} [params.entityType] Associated entity type (e.g. 'company', 'employee', 'inspection')
|
|
197
|
-
* @param {string|number} [params.entityId] Associated entity ID
|
|
198
|
-
* @param {string} [params.apiKey] Override API Key for this upload
|
|
199
|
-
* @param {AbortSignal} [params.signal] AbortSignal for cancellation
|
|
200
|
-
* @returns {Promise<{ files: Array<object>, file: object|null }>} Formatted upload result
|
|
201
|
-
*/
|
|
202
|
-
async upload({
|
|
203
|
-
files,
|
|
204
|
-
file,
|
|
205
|
-
folder,
|
|
206
|
-
entityType,
|
|
207
|
-
entityId,
|
|
208
|
-
entity_type,
|
|
209
|
-
entity_id,
|
|
210
|
-
apiKey,
|
|
211
|
-
signal,
|
|
212
|
-
} = {}) {
|
|
213
|
-
if (typeof FormData === 'undefined') {
|
|
214
|
-
throw new Error('[cynx/storage] FormData is not available in the current environment.');
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const formData = new FormData();
|
|
218
|
-
const rawFiles = files ? (Array.isArray(files) ? files : [files]) : file ? [file] : [];
|
|
219
|
-
|
|
220
|
-
if (rawFiles.length === 0) {
|
|
221
|
-
throw new Error('[cynx/storage] No file provided for upload.');
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
for (let i = 0; i < rawFiles.length; i++) {
|
|
225
|
-
const item = rawFiles[i];
|
|
226
|
-
if (!item) continue;
|
|
227
|
-
|
|
228
|
-
if (typeof File !== 'undefined' && item instanceof File) {
|
|
229
|
-
formData.append('files', item, item.name);
|
|
230
|
-
} else if (typeof Blob !== 'undefined' && item instanceof Blob) {
|
|
231
|
-
const name = item.name || `file_${Date.now()}_${i}`;
|
|
232
|
-
formData.append('files', item, name);
|
|
233
|
-
} else if (isNode && (Buffer.isBuffer(item) || item instanceof Uint8Array || item instanceof ArrayBuffer)) {
|
|
234
|
-
const name = item.name || item.filename || `file_${Date.now()}_${i}`;
|
|
235
|
-
const blob = new Blob([item], { type: item.type || item.mimeType || 'application/octet-stream' });
|
|
236
|
-
formData.append('files', blob, name);
|
|
237
|
-
} else if (item && typeof item === 'object' && item.data) {
|
|
238
|
-
// Wrapped object format: { data, name, type }
|
|
239
|
-
const name = item.name || item.filename || `file_${Date.now()}_${i}`;
|
|
240
|
-
const blob = item.data instanceof Blob ? item.data : new Blob([item.data], { type: item.type || item.mimeType || 'application/octet-stream' });
|
|
241
|
-
formData.append('files', blob, name);
|
|
242
|
-
} else {
|
|
243
|
-
formData.append('files', item);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if (folder) formData.append('folder', folder);
|
|
248
|
-
const resolvedEntityType = entityType || entity_type;
|
|
249
|
-
const resolvedEntityId = entityId || entity_id;
|
|
250
|
-
|
|
251
|
-
if (resolvedEntityType) formData.append('entityType', resolvedEntityType);
|
|
252
|
-
if (resolvedEntityId) formData.append('entityId', String(resolvedEntityId));
|
|
253
|
-
|
|
254
|
-
const res = await this._request('/api/v1/upload', {
|
|
255
|
-
method: 'POST',
|
|
256
|
-
body: formData,
|
|
257
|
-
apiKey,
|
|
258
|
-
signal,
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
const fileList = res?.files ? res.files : Array.isArray(res) ? res : res ? [res] : [];
|
|
262
|
-
return {
|
|
263
|
-
files: fileList,
|
|
264
|
-
file: fileList[0] || null,
|
|
265
|
-
...res,
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Get metadata and CDN URLs for a specific file by its ID.
|
|
271
|
-
*
|
|
272
|
-
* @param {string} fileId ID of the file (e.g. "sf_abc123")
|
|
273
|
-
* @param {object} [options]
|
|
274
|
-
* @param {string} [options.apiKey] Override API Key
|
|
275
|
-
* @param {AbortSignal} [options.signal] AbortSignal
|
|
276
|
-
* @returns {Promise<object>} File metadata record
|
|
277
|
-
*/
|
|
278
|
-
async getFile(fileId, options = {}) {
|
|
279
|
-
if (!fileId) throw new Error('[cynx/storage] fileId is required.');
|
|
280
|
-
const cleanId = String(fileId).trim().replace(/\.[^/.]+$/, ''); // strip any extension if present
|
|
281
|
-
return this._request(`/api/v1/files/${encodeURIComponent(cleanId)}`, {
|
|
282
|
-
method: 'GET',
|
|
283
|
-
...options,
|
|
284
|
-
});
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* Get all files attached to a specific entity.
|
|
289
|
-
*
|
|
290
|
-
* @param {string} entityType Entity type (e.g. "company", "employee")
|
|
291
|
-
* @param {string|number} entityId Entity identifier
|
|
292
|
-
* @param {object} [options]
|
|
293
|
-
* @param {string} [options.apiKey] Override API Key
|
|
294
|
-
* @param {AbortSignal} [options.signal] AbortSignal
|
|
295
|
-
* @returns {Promise<Array<object>>} List of file metadata records
|
|
296
|
-
*/
|
|
297
|
-
async getEntityFiles(entityType, entityId, options = {}) {
|
|
298
|
-
if (!entityType || entityId === undefined || entityId === null) {
|
|
299
|
-
throw new Error('[cynx/storage] entityType and entityId are required.');
|
|
300
|
-
}
|
|
301
|
-
const res = await this._request(
|
|
302
|
-
`/api/v1/entity/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`,
|
|
303
|
-
{ method: 'GET', ...options }
|
|
304
|
-
);
|
|
305
|
-
return Array.isArray(res) ? res : [];
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Delete a single file by ID.
|
|
310
|
-
*
|
|
311
|
-
* @param {string} fileId ID of the file to delete
|
|
312
|
-
* @param {object} [options]
|
|
313
|
-
* @param {string} [options.apiKey] Override API Key
|
|
314
|
-
* @returns {Promise<{ message: string, fileId: string }>}
|
|
315
|
-
*/
|
|
316
|
-
async deleteFile(fileId, options = {}) {
|
|
317
|
-
if (!fileId) throw new Error('[cynx/storage] fileId is required.');
|
|
318
|
-
const cleanId = String(fileId).trim().replace(/\.[^/.]+$/, '');
|
|
319
|
-
return this._request(`/api/v1/files/${encodeURIComponent(cleanId)}`, {
|
|
320
|
-
method: 'DELETE',
|
|
321
|
-
...options,
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* Delete all files belonging to a specific entity.
|
|
327
|
-
*
|
|
328
|
-
* @param {string} entityType Entity type
|
|
329
|
-
* @param {string|number} entityId Entity identifier
|
|
330
|
-
* @param {object} [options]
|
|
331
|
-
* @param {string} [options.apiKey] Override API Key
|
|
332
|
-
* @returns {Promise<{ message: string, deletedCount: number }>}
|
|
333
|
-
*/
|
|
334
|
-
async deleteEntityFiles(entityType, entityId, options = {}) {
|
|
335
|
-
if (!entityType || entityId === undefined || entityId === null) {
|
|
336
|
-
throw new Error('[cynx/storage] entityType and entityId are required.');
|
|
337
|
-
}
|
|
338
|
-
return this._request(
|
|
339
|
-
`/api/v1/entity/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`,
|
|
340
|
-
{ method: 'DELETE', ...options }
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* Generate a public CDN streaming/viewing URL for a file.
|
|
346
|
-
*
|
|
347
|
-
* @param {string} fileId File ID
|
|
348
|
-
* @param {string} [filename] Optional clean filename for browser save/view
|
|
349
|
-
* @returns {string} Complete CDN URL
|
|
350
|
-
*/
|
|
351
|
-
getCdnUrl(fileId, filename = '') {
|
|
352
|
-
return getCdnUrl(fileId, filename, this.baseUrl);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Download a file from CDN as a standard Fetch Response.
|
|
357
|
-
*
|
|
358
|
-
* @param {string} fileId File ID
|
|
359
|
-
* @param {object} [options]
|
|
360
|
-
* @param {string} [options.filename]
|
|
361
|
-
* @param {AbortSignal} [options.signal]
|
|
362
|
-
* @returns {Promise<Response>}
|
|
363
|
-
*/
|
|
364
|
-
async download(fileId, options = {}) {
|
|
365
|
-
if (!this.fetchFn) {
|
|
366
|
-
throw new Error('[cynx/storage] No fetch implementation available.');
|
|
367
|
-
}
|
|
368
|
-
const url = this.getCdnUrl(fileId, options.filename);
|
|
369
|
-
const res = await this.fetchFn(url, { signal: options.signal });
|
|
370
|
-
if (!res.ok) {
|
|
371
|
-
throw new Error(`Failed to download file: HTTP ${res.status} ${res.statusText}`);
|
|
372
|
-
}
|
|
373
|
-
return res;
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Factory function to create a new StorageClient instance.
|
|
379
|
-
*
|
|
380
|
-
* @param {object} [options]
|
|
381
|
-
* @returns {StorageClient}
|
|
382
|
-
*/
|
|
383
|
-
export function createStorageClient(options = {}) {
|
|
384
|
-
return new StorageClient(options);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
export default {
|
|
388
|
-
StorageClient,
|
|
389
|
-
createStorageClient,
|
|
390
|
-
getCdnUrl,
|
|
391
|
-
getFileTypeCategory,
|
|
392
|
-
formatBytes,
|
|
393
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* cynx/utils — Storage Server Client & Connector (storage.js)
|
|
3
|
+
*
|
|
4
|
+
* Universal client SDK to interact with cynx-storage-server:
|
|
5
|
+
* - Upload files (Browser File/Blob or Node.js Buffer/Stream/Blob)
|
|
6
|
+
* - Query file metadata & entity attachments
|
|
7
|
+
* - Delete files or whole entity file groups
|
|
8
|
+
* - Generate public CDN URLs and stream content
|
|
9
|
+
*
|
|
10
|
+
* Works in Browser, Node.js (18+), Next.js / SSR, and Web Workers.
|
|
11
|
+
* Zero external dependencies.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ─── UTILITIES & HELPERS ────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const isNode = typeof process !== 'undefined' && Boolean(process.versions?.node);
|
|
17
|
+
|
|
18
|
+
function getDefaultBaseUrl() {
|
|
19
|
+
if (isNode && process.env?.STORAGE_SERVER_URL) {
|
|
20
|
+
return process.env.STORAGE_SERVER_URL;
|
|
21
|
+
}
|
|
22
|
+
if (typeof window !== 'undefined' && window.location?.origin) {
|
|
23
|
+
return window.location.origin;
|
|
24
|
+
}
|
|
25
|
+
return '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getDefaultApiKey() {
|
|
29
|
+
if (isNode && (process.env?.STORAGE_SERVER_API_KEY || process.env?.STORAGE_API_KEY)) {
|
|
30
|
+
return process.env.STORAGE_SERVER_API_KEY || process.env.STORAGE_API_KEY;
|
|
31
|
+
}
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Format raw byte numbers into human-readable strings.
|
|
37
|
+
* @param {number|string} bytes
|
|
38
|
+
* @returns {string} e.g. "12.5 MB"
|
|
39
|
+
*/
|
|
40
|
+
export function formatBytes(bytes) {
|
|
41
|
+
const num = Number(bytes) || 0;
|
|
42
|
+
if (num === 0) return '0 B';
|
|
43
|
+
const k = 1024;
|
|
44
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
45
|
+
const i = Math.floor(Math.log(num) / Math.log(k));
|
|
46
|
+
const val = (num / Math.pow(k, i)).toFixed(2);
|
|
47
|
+
return `${val} ${sizes[i]}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Categorize mimeType and filename into categories.
|
|
52
|
+
* @param {string} [mimeType='']
|
|
53
|
+
* @param {string} [filename='']
|
|
54
|
+
* @returns {'image'|'pdf'|'video'|'audio'|'document'|'archive'|'other'}
|
|
55
|
+
*/
|
|
56
|
+
export function getFileTypeCategory(mimeType = '', filename = '') {
|
|
57
|
+
const mt = (mimeType || '').toLowerCase();
|
|
58
|
+
const fn = (filename || '').toLowerCase();
|
|
59
|
+
|
|
60
|
+
if (mt.startsWith('image/') || /\.(jpg|jpeg|png|gif|webp|bmp|svg|avif|ico)$/i.test(fn)) {
|
|
61
|
+
return 'image';
|
|
62
|
+
}
|
|
63
|
+
if (mt === 'application/pdf' || /\.pdf$/i.test(fn)) {
|
|
64
|
+
return 'pdf';
|
|
65
|
+
}
|
|
66
|
+
if (mt.startsWith('video/') || /\.(mp4|webm|mkv|mov|avi|flv|wmv|m4v)$/i.test(fn)) {
|
|
67
|
+
return 'video';
|
|
68
|
+
}
|
|
69
|
+
if (mt.startsWith('audio/') || /\.(mp3|wav|ogg|m4a|aac|flac)$/i.test(fn)) {
|
|
70
|
+
return 'audio';
|
|
71
|
+
}
|
|
72
|
+
if (
|
|
73
|
+
mt.includes('word') ||
|
|
74
|
+
mt.includes('excel') ||
|
|
75
|
+
mt.includes('spreadsheet') ||
|
|
76
|
+
mt.includes('presentation') ||
|
|
77
|
+
mt.includes('csv') ||
|
|
78
|
+
/\.(doc|docx|xls|xlsx|csv|ppt|pptx|txt|rtf|md)$/i.test(fn)
|
|
79
|
+
) {
|
|
80
|
+
return 'document';
|
|
81
|
+
}
|
|
82
|
+
if (mt.includes('zip') || mt.includes('tar') || mt.includes('compressed') || /\.(zip|tar|gz|7z|rar)$/i.test(fn)) {
|
|
83
|
+
return 'archive';
|
|
84
|
+
}
|
|
85
|
+
return 'other';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build a public CDN URL for a given file ID.
|
|
90
|
+
* @param {string} fileId e.g. "sf_abc123"
|
|
91
|
+
* @param {string} [filename] Optional filename suffix for cleaner download names
|
|
92
|
+
* @param {string} [baseUrl] Storage server base URL
|
|
93
|
+
* @returns {string} CDN URL
|
|
94
|
+
*/
|
|
95
|
+
export function getCdnUrl(fileId, filename = '', baseUrl = '') {
|
|
96
|
+
if (!fileId) return '';
|
|
97
|
+
const base = (baseUrl || getDefaultBaseUrl() || '').replace(/\/+$/, '');
|
|
98
|
+
const cleanId = String(fileId).trim();
|
|
99
|
+
const suffix = filename ? `/${encodeURIComponent(filename)}` : '';
|
|
100
|
+
return `${base}/cdn/${encodeURIComponent(cleanId)}${suffix}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── STORAGE CLIENT CLASS ───────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
export class StorageClient {
|
|
106
|
+
/**
|
|
107
|
+
* @param {object} [options={}]
|
|
108
|
+
* @param {string} [options.baseUrl] Base URL of the storage server (e.g. "https://storage.domain.com" or "http://localhost:5000")
|
|
109
|
+
* @param {string} [options.apiKey] API Key with upload/read/delete permissions
|
|
110
|
+
* @param {number} [options.timeout=30000] Request timeout in milliseconds
|
|
111
|
+
* @param {Function} [options.fetch] Custom fetch implementation (optional)
|
|
112
|
+
*/
|
|
113
|
+
constructor(options = {}) {
|
|
114
|
+
this.baseUrl = (options.baseUrl || getDefaultBaseUrl() || '').replace(/\/+$/, '');
|
|
115
|
+
this.apiKey = options.apiKey || getDefaultApiKey() || '';
|
|
116
|
+
this.timeout = options.timeout || 30000;
|
|
117
|
+
this.fetchFn = options.fetch || (typeof globalThis !== 'undefined' ? globalThis.fetch?.bind(globalThis) : null);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Update configuration options.
|
|
122
|
+
* @param {object} options
|
|
123
|
+
*/
|
|
124
|
+
configure(options = {}) {
|
|
125
|
+
if (options.baseUrl !== undefined) this.baseUrl = String(options.baseUrl || '').replace(/\/+$/, '');
|
|
126
|
+
if (options.apiKey !== undefined) this.apiKey = String(options.apiKey || '');
|
|
127
|
+
if (options.timeout !== undefined) this.timeout = Number(options.timeout) || 30000;
|
|
128
|
+
if (options.fetch !== undefined) this.fetchFn = options.fetch;
|
|
129
|
+
return this;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Internal HTTP request helper.
|
|
134
|
+
* @private
|
|
135
|
+
*/
|
|
136
|
+
async _request(path, { method = 'GET', headers = {}, body, apiKey, signal, timeout } = {}) {
|
|
137
|
+
if (!this.fetchFn) {
|
|
138
|
+
throw new Error('[cynx/storage] No fetch implementation available in current environment.');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const url = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
|
142
|
+
const key = apiKey !== undefined ? apiKey : this.apiKey;
|
|
143
|
+
const reqHeaders = { ...headers };
|
|
144
|
+
|
|
145
|
+
if (key) {
|
|
146
|
+
reqHeaders['x-api-key'] = key;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const abortCtrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
150
|
+
const reqTimeout = timeout || this.timeout;
|
|
151
|
+
let timer = null;
|
|
152
|
+
|
|
153
|
+
if (abortCtrl && reqTimeout > 0) {
|
|
154
|
+
timer = setTimeout(() => abortCtrl.abort(new Error(`Storage request timeout after ${reqTimeout}ms`)), reqTimeout);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const activeSignal = signal || abortCtrl?.signal;
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const response = await this.fetchFn(url, {
|
|
161
|
+
method,
|
|
162
|
+
headers: reqHeaders,
|
|
163
|
+
body,
|
|
164
|
+
signal: activeSignal,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (timer) clearTimeout(timer);
|
|
168
|
+
|
|
169
|
+
if (!response.ok) {
|
|
170
|
+
let errData = null;
|
|
171
|
+
try {
|
|
172
|
+
errData = await response.json();
|
|
173
|
+
} catch {
|
|
174
|
+
errData = { error: response.statusText || `HTTP ${response.status}` };
|
|
175
|
+
}
|
|
176
|
+
const error = new Error(errData.error || errData.message || `Storage Error: ${response.status}`);
|
|
177
|
+
error.status = response.status;
|
|
178
|
+
error.response = errData;
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return await response.json();
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (timer) clearTimeout(timer);
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Upload one or multiple files to the storage server.
|
|
191
|
+
*
|
|
192
|
+
* @param {object} params
|
|
193
|
+
* @param {File|Blob|Buffer|ArrayBuffer|Array<File|Blob|Buffer|object>} [params.files] File or array of files to upload
|
|
194
|
+
* @param {File|Blob|Buffer|ArrayBuffer} [params.file] Single file alias
|
|
195
|
+
* @param {string} [params.folder] Target subfolder in cloud storage
|
|
196
|
+
* @param {string} [params.entityType] Associated entity type (e.g. 'company', 'employee', 'inspection')
|
|
197
|
+
* @param {string|number} [params.entityId] Associated entity ID
|
|
198
|
+
* @param {string} [params.apiKey] Override API Key for this upload
|
|
199
|
+
* @param {AbortSignal} [params.signal] AbortSignal for cancellation
|
|
200
|
+
* @returns {Promise<{ files: Array<object>, file: object|null }>} Formatted upload result
|
|
201
|
+
*/
|
|
202
|
+
async upload({
|
|
203
|
+
files,
|
|
204
|
+
file,
|
|
205
|
+
folder,
|
|
206
|
+
entityType,
|
|
207
|
+
entityId,
|
|
208
|
+
entity_type,
|
|
209
|
+
entity_id,
|
|
210
|
+
apiKey,
|
|
211
|
+
signal,
|
|
212
|
+
} = {}) {
|
|
213
|
+
if (typeof FormData === 'undefined') {
|
|
214
|
+
throw new Error('[cynx/storage] FormData is not available in the current environment.');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const formData = new FormData();
|
|
218
|
+
const rawFiles = files ? (Array.isArray(files) ? files : [files]) : file ? [file] : [];
|
|
219
|
+
|
|
220
|
+
if (rawFiles.length === 0) {
|
|
221
|
+
throw new Error('[cynx/storage] No file provided for upload.');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (let i = 0; i < rawFiles.length; i++) {
|
|
225
|
+
const item = rawFiles[i];
|
|
226
|
+
if (!item) continue;
|
|
227
|
+
|
|
228
|
+
if (typeof File !== 'undefined' && item instanceof File) {
|
|
229
|
+
formData.append('files', item, item.name);
|
|
230
|
+
} else if (typeof Blob !== 'undefined' && item instanceof Blob) {
|
|
231
|
+
const name = item.name || `file_${Date.now()}_${i}`;
|
|
232
|
+
formData.append('files', item, name);
|
|
233
|
+
} else if (isNode && (Buffer.isBuffer(item) || item instanceof Uint8Array || item instanceof ArrayBuffer)) {
|
|
234
|
+
const name = item.name || item.filename || `file_${Date.now()}_${i}`;
|
|
235
|
+
const blob = new Blob([item], { type: item.type || item.mimeType || 'application/octet-stream' });
|
|
236
|
+
formData.append('files', blob, name);
|
|
237
|
+
} else if (item && typeof item === 'object' && item.data) {
|
|
238
|
+
// Wrapped object format: { data, name, type }
|
|
239
|
+
const name = item.name || item.filename || `file_${Date.now()}_${i}`;
|
|
240
|
+
const blob = item.data instanceof Blob ? item.data : new Blob([item.data], { type: item.type || item.mimeType || 'application/octet-stream' });
|
|
241
|
+
formData.append('files', blob, name);
|
|
242
|
+
} else {
|
|
243
|
+
formData.append('files', item);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (folder) formData.append('folder', folder);
|
|
248
|
+
const resolvedEntityType = entityType || entity_type;
|
|
249
|
+
const resolvedEntityId = entityId || entity_id;
|
|
250
|
+
|
|
251
|
+
if (resolvedEntityType) formData.append('entityType', resolvedEntityType);
|
|
252
|
+
if (resolvedEntityId) formData.append('entityId', String(resolvedEntityId));
|
|
253
|
+
|
|
254
|
+
const res = await this._request('/api/v1/upload', {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
body: formData,
|
|
257
|
+
apiKey,
|
|
258
|
+
signal,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const fileList = res?.files ? res.files : Array.isArray(res) ? res : res ? [res] : [];
|
|
262
|
+
return {
|
|
263
|
+
files: fileList,
|
|
264
|
+
file: fileList[0] || null,
|
|
265
|
+
...res,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Get metadata and CDN URLs for a specific file by its ID.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} fileId ID of the file (e.g. "sf_abc123")
|
|
273
|
+
* @param {object} [options]
|
|
274
|
+
* @param {string} [options.apiKey] Override API Key
|
|
275
|
+
* @param {AbortSignal} [options.signal] AbortSignal
|
|
276
|
+
* @returns {Promise<object>} File metadata record
|
|
277
|
+
*/
|
|
278
|
+
async getFile(fileId, options = {}) {
|
|
279
|
+
if (!fileId) throw new Error('[cynx/storage] fileId is required.');
|
|
280
|
+
const cleanId = String(fileId).trim().replace(/\.[^/.]+$/, ''); // strip any extension if present
|
|
281
|
+
return this._request(`/api/v1/files/${encodeURIComponent(cleanId)}`, {
|
|
282
|
+
method: 'GET',
|
|
283
|
+
...options,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Get all files attached to a specific entity.
|
|
289
|
+
*
|
|
290
|
+
* @param {string} entityType Entity type (e.g. "company", "employee")
|
|
291
|
+
* @param {string|number} entityId Entity identifier
|
|
292
|
+
* @param {object} [options]
|
|
293
|
+
* @param {string} [options.apiKey] Override API Key
|
|
294
|
+
* @param {AbortSignal} [options.signal] AbortSignal
|
|
295
|
+
* @returns {Promise<Array<object>>} List of file metadata records
|
|
296
|
+
*/
|
|
297
|
+
async getEntityFiles(entityType, entityId, options = {}) {
|
|
298
|
+
if (!entityType || entityId === undefined || entityId === null) {
|
|
299
|
+
throw new Error('[cynx/storage] entityType and entityId are required.');
|
|
300
|
+
}
|
|
301
|
+
const res = await this._request(
|
|
302
|
+
`/api/v1/entity/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`,
|
|
303
|
+
{ method: 'GET', ...options }
|
|
304
|
+
);
|
|
305
|
+
return Array.isArray(res) ? res : [];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Delete a single file by ID.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} fileId ID of the file to delete
|
|
312
|
+
* @param {object} [options]
|
|
313
|
+
* @param {string} [options.apiKey] Override API Key
|
|
314
|
+
* @returns {Promise<{ message: string, fileId: string }>}
|
|
315
|
+
*/
|
|
316
|
+
async deleteFile(fileId, options = {}) {
|
|
317
|
+
if (!fileId) throw new Error('[cynx/storage] fileId is required.');
|
|
318
|
+
const cleanId = String(fileId).trim().replace(/\.[^/.]+$/, '');
|
|
319
|
+
return this._request(`/api/v1/files/${encodeURIComponent(cleanId)}`, {
|
|
320
|
+
method: 'DELETE',
|
|
321
|
+
...options,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Delete all files belonging to a specific entity.
|
|
327
|
+
*
|
|
328
|
+
* @param {string} entityType Entity type
|
|
329
|
+
* @param {string|number} entityId Entity identifier
|
|
330
|
+
* @param {object} [options]
|
|
331
|
+
* @param {string} [options.apiKey] Override API Key
|
|
332
|
+
* @returns {Promise<{ message: string, deletedCount: number }>}
|
|
333
|
+
*/
|
|
334
|
+
async deleteEntityFiles(entityType, entityId, options = {}) {
|
|
335
|
+
if (!entityType || entityId === undefined || entityId === null) {
|
|
336
|
+
throw new Error('[cynx/storage] entityType and entityId are required.');
|
|
337
|
+
}
|
|
338
|
+
return this._request(
|
|
339
|
+
`/api/v1/entity/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`,
|
|
340
|
+
{ method: 'DELETE', ...options }
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Generate a public CDN streaming/viewing URL for a file.
|
|
346
|
+
*
|
|
347
|
+
* @param {string} fileId File ID
|
|
348
|
+
* @param {string} [filename] Optional clean filename for browser save/view
|
|
349
|
+
* @returns {string} Complete CDN URL
|
|
350
|
+
*/
|
|
351
|
+
getCdnUrl(fileId, filename = '') {
|
|
352
|
+
return getCdnUrl(fileId, filename, this.baseUrl);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Download a file from CDN as a standard Fetch Response.
|
|
357
|
+
*
|
|
358
|
+
* @param {string} fileId File ID
|
|
359
|
+
* @param {object} [options]
|
|
360
|
+
* @param {string} [options.filename]
|
|
361
|
+
* @param {AbortSignal} [options.signal]
|
|
362
|
+
* @returns {Promise<Response>}
|
|
363
|
+
*/
|
|
364
|
+
async download(fileId, options = {}) {
|
|
365
|
+
if (!this.fetchFn) {
|
|
366
|
+
throw new Error('[cynx/storage] No fetch implementation available.');
|
|
367
|
+
}
|
|
368
|
+
const url = this.getCdnUrl(fileId, options.filename);
|
|
369
|
+
const res = await this.fetchFn(url, { signal: options.signal });
|
|
370
|
+
if (!res.ok) {
|
|
371
|
+
throw new Error(`Failed to download file: HTTP ${res.status} ${res.statusText}`);
|
|
372
|
+
}
|
|
373
|
+
return res;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Factory function to create a new StorageClient instance.
|
|
379
|
+
*
|
|
380
|
+
* @param {object} [options]
|
|
381
|
+
* @returns {StorageClient}
|
|
382
|
+
*/
|
|
383
|
+
export function createStorageClient(options = {}) {
|
|
384
|
+
return new StorageClient(options);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export default {
|
|
388
|
+
StorageClient,
|
|
389
|
+
createStorageClient,
|
|
390
|
+
getCdnUrl,
|
|
391
|
+
getFileTypeCategory,
|
|
392
|
+
formatBytes,
|
|
393
|
+
};
|