cynx-lib 1.3.41 → 1.3.42
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/index.cjs +18 -0
- package/package.json +31 -7
- package/ui/index.cjs +182 -0
- package/utils/index.cjs +80 -0
- package/utils/utils/db.cjs +779 -0
- package/utils/utils/duration.cjs +119 -0
- package/utils/utils/oidc.cjs +164 -0
- package/utils/utils/realtime.cjs +881 -0
- package/utils/utils/session.cjs +61 -0
- package/utils/utils/storage.cjs +344 -0
- package/utils/utils/storage_manager.cjs +1177 -0
- package/utils/utils/tz.cjs +542 -0
- package/utils/utils/ui.cjs +105 -0
- package/utils/utils/uid.cjs +187 -0
|
@@ -0,0 +1,1177 @@
|
|
|
1
|
+
var __create = Object.create;
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
20
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
21
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
22
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
23
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
24
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
25
|
+
mod
|
|
26
|
+
));
|
|
27
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
28
|
+
var storage_manager_exports = {};
|
|
29
|
+
__export(storage_manager_exports, {
|
|
30
|
+
STORAGE_PROVIDERS: () => STORAGE_PROVIDERS,
|
|
31
|
+
StorageManager: () => StorageManager,
|
|
32
|
+
createStorageManager: () => createStorageManager,
|
|
33
|
+
default: () => storage_manager_default
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(storage_manager_exports);
|
|
36
|
+
var import_fs = __toESM(require("fs"), 1);
|
|
37
|
+
var import_path = __toESM(require("path"), 1);
|
|
38
|
+
var import_http = __toESM(require("http"), 1);
|
|
39
|
+
var import_https = __toESM(require("https"), 1);
|
|
40
|
+
var import_zlib = __toESM(require("zlib"), 1);
|
|
41
|
+
var import_uid = require("./uid.js");
|
|
42
|
+
var import_storage = require("./storage.js");
|
|
43
|
+
const httpsAgent = new import_https.default.Agent({
|
|
44
|
+
keepAlive: true,
|
|
45
|
+
keepAliveMsecs: 6e4,
|
|
46
|
+
maxSockets: 64,
|
|
47
|
+
maxFreeSockets: 32,
|
|
48
|
+
timeout: 3e4
|
|
49
|
+
});
|
|
50
|
+
const httpAgent = new import_http.default.Agent({
|
|
51
|
+
keepAlive: true,
|
|
52
|
+
keepAliveMsecs: 6e4,
|
|
53
|
+
maxSockets: 64,
|
|
54
|
+
maxFreeSockets: 32,
|
|
55
|
+
timeout: 3e4
|
|
56
|
+
});
|
|
57
|
+
const STORAGE_PROVIDERS = [
|
|
58
|
+
{
|
|
59
|
+
id: "s3",
|
|
60
|
+
name: "Amazon S3",
|
|
61
|
+
description: "Amazon Web Services S3 Object Storage",
|
|
62
|
+
fields: [
|
|
63
|
+
{ key: "bucket", label: "\u0418\u043C\u044F \u0431\u0430\u043A\u0435\u0442\u0430 (Bucket Name)", placeholder: "my-company-storage", required: true },
|
|
64
|
+
{ key: "region", label: "\u0420\u0435\u0433\u0438\u043E\u043D AWS (Region)", placeholder: "us-east-1", required: true, default: "us-east-1" },
|
|
65
|
+
{ key: "accessKeyId", label: "Access Key ID", placeholder: "AKIA...", required: true },
|
|
66
|
+
{ key: "secretAccessKey", label: "Secret Access Key", type: "password", placeholder: "AWS Secret Key", required: true },
|
|
67
|
+
{ key: "publicDomain", label: "\u041F\u0443\u0431\u043B\u0438\u0447\u043D\u044B\u0439 \u0434\u043E\u043C\u0435\u043D / CloudFront URL (\u043E\u043F\u0446\u0438\u043E\u043D\u0430\u043B\u044C\u043D\u043E)", placeholder: "https://cdn.mycompany.com" },
|
|
68
|
+
{ key: "folder", label: "\u041F\u0430\u043F\u043A\u0430 \u0432 \u0431\u0430\u043A\u0435\u0442\u0435 (\u043E\u043F\u0446\u0438\u043E\u043D\u0430\u043B\u044C\u043D\u043E)", placeholder: "uploads" },
|
|
69
|
+
{ key: "endpoint", label: "\u041A\u0430\u0441\u0442\u043E\u043C\u043D\u044B\u0439 Endpoint URL (\u043E\u043F\u0446\u0438\u043E\u043D\u0430\u043B\u044C\u043D\u043E, \u0434\u043B\u044F MinIO/LocalStack)", placeholder: "https://s3.custom-domain.com" }
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "r2",
|
|
74
|
+
name: "Cloudflare R2",
|
|
75
|
+
description: "Cloudflare R2 S3-Compatible Storage (Zero egress fee)",
|
|
76
|
+
fields: [
|
|
77
|
+
{ key: "bucket", label: "\u0418\u043C\u044F \u0431\u0430\u043A\u0435\u0442\u0430 R2 (Bucket Name)", placeholder: "my-r2-bucket", required: true },
|
|
78
|
+
{ key: "accountId", label: "Cloudflare Account ID", placeholder: "f1234567890abcdef...", required: true },
|
|
79
|
+
{ key: "accessKeyId", label: "R2 Access Key ID", placeholder: "24-character hex ID", required: true },
|
|
80
|
+
{ key: "secretAccessKey", label: "R2 Secret Access Key", type: "password", placeholder: "64-character hex secret", required: true },
|
|
81
|
+
{ key: "publicDomain", label: "\u041F\u0443\u0431\u043B\u0438\u0447\u043D\u044B\u0439 \u0434\u043E\u043C\u0435\u043D R2 (\u043E\u043F\u0446\u0438\u043E\u043D\u0430\u043B\u044C\u043D\u043E)", placeholder: "https://pub-xxx.r2.dev \u0438\u043B\u0438 https://cdn.mycompany.com" },
|
|
82
|
+
{ key: "folder", label: "\u041F\u0430\u043F\u043A\u0430 \u0432 \u0431\u0430\u043A\u0435\u0442\u0435 (\u043E\u043F\u0446\u0438\u043E\u043D\u0430\u043B\u044C\u043D\u043E)", placeholder: "uploads" }
|
|
83
|
+
]
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "cloudinary",
|
|
87
|
+
name: "Cloudinary",
|
|
88
|
+
description: "Cloudinary Media Optimization & Storage",
|
|
89
|
+
fields: [
|
|
90
|
+
{ key: "cloudName", label: "Cloud Name", placeholder: "dXXXXXXX", required: true },
|
|
91
|
+
{ key: "apiKey", label: "API Key", placeholder: "1234567890", required: true },
|
|
92
|
+
{ key: "apiSecret", label: "API Secret", type: "password", placeholder: "API Secret key", required: true },
|
|
93
|
+
{ key: "folder", label: "\u041F\u0430\u043F\u043A\u0430 \u0432 Cloudinary (\u043E\u043F\u0446\u0438\u043E\u043D\u0430\u043B\u044C\u043D\u043E)", placeholder: "emp_storage" }
|
|
94
|
+
]
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "vercel_blob",
|
|
98
|
+
name: "Vercel Blob",
|
|
99
|
+
description: "Vercel Edge Global Blob Storage",
|
|
100
|
+
fields: [
|
|
101
|
+
{ key: "readWriteToken", label: "Read/Write Token (BLOB_READ_WRITE_TOKEN)", type: "password", placeholder: "vercel_blob_rw_...", required: true },
|
|
102
|
+
{ key: "folder", label: "\u041F\u0430\u043F\u043A\u0430 \u0432 Vercel Blob (\u043E\u043F\u0446\u0438\u043E\u043D\u0430\u043B\u044C\u043D\u043E)", placeholder: "emp-storage" }
|
|
103
|
+
]
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: "supabase",
|
|
107
|
+
name: "Supabase Storage",
|
|
108
|
+
description: "Supabase S3-compatible REST Object Storage",
|
|
109
|
+
fields: [
|
|
110
|
+
{ key: "projectUrl", label: "Project URL", placeholder: "https://xyzcompany.supabase.co", required: true },
|
|
111
|
+
{ key: "serviceKey", label: "Service Role Key", type: "password", placeholder: "eyJhbGciOi...", required: true },
|
|
112
|
+
{ key: "bucket", label: "\u0418\u043C\u044F \u0431\u0430\u043A\u0435\u0442\u0430 (Bucket Name)", placeholder: "storage", required: true },
|
|
113
|
+
{ key: "folder", label: "\u041F\u0430\u043F\u043A\u0430 (\u043E\u043F\u0446\u0438\u043E\u043D\u0430\u043B\u044C\u043D\u043E)", placeholder: "uploads" },
|
|
114
|
+
{ key: "publicDomain", label: "\u041F\u0443\u0431\u043B\u0438\u0447\u043D\u044B\u0439 \u0434\u043E\u043C\u0435\u043D (\u043E\u043F\u0446\u0438\u043E\u043D\u0430\u043B\u044C\u043D\u043E)", placeholder: "https://cdn.supabase.com" }
|
|
115
|
+
]
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: "gcs",
|
|
119
|
+
name: "Google Cloud Storage",
|
|
120
|
+
description: "Google Cloud Platform Storage Buckets",
|
|
121
|
+
fields: [
|
|
122
|
+
{ key: "bucket", label: "\u0418\u043C\u044F \u0431\u0430\u043A\u0435\u0442\u0430 GCS (Bucket Name)", placeholder: "my-gcs-bucket", required: true },
|
|
123
|
+
{ key: "projectId", label: "Project ID", placeholder: "my-gcp-project", required: true },
|
|
124
|
+
{ key: "clientEmail", label: "Service Account Email", placeholder: "sa@project.iam.gserviceaccount.com", required: true },
|
|
125
|
+
{ key: "privateKey", label: "Private Key (PEM)", type: "password", placeholder: "-----BEGIN PRIVATE KEY-----\n...", required: true },
|
|
126
|
+
{ key: "publicDomain", label: "\u041F\u0443\u0431\u043B\u0438\u0447\u043D\u044B\u0439 \u0434\u043E\u043C\u0435\u043D (\u043E\u043F\u0446\u0438\u043E\u043D\u0430\u043B\u044C\u043D\u043E)", placeholder: "https://storage.googleapis.com/my-bucket" },
|
|
127
|
+
{ key: "folder", label: "\u041F\u0430\u043F\u043A\u0430 (\u043E\u043F\u0446\u0438\u043E\u043D\u0430\u043B\u044C\u043D\u043E)", placeholder: "uploads" }
|
|
128
|
+
]
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: "azure_blob",
|
|
132
|
+
name: "Azure Blob Storage",
|
|
133
|
+
description: "Microsoft Azure Blob Storage",
|
|
134
|
+
fields: [
|
|
135
|
+
{ key: "containerName", label: "Container Name", placeholder: "my-container", required: true },
|
|
136
|
+
{ key: "connectionString", label: "Connection String (\u0438\u043B\u0438 \u043E\u0441\u0442\u0430\u0432\u044C\u0442\u0435 \u043F\u0443\u0441\u0442\u044B\u043C \u0438 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0439\u0442\u0435 Account Key)", type: "password", placeholder: "DefaultEndpointsProtocol=https;AccountName=..." },
|
|
137
|
+
{ key: "accountName", label: "Storage Account Name", placeholder: "mystorageaccount" },
|
|
138
|
+
{ key: "accountKey", label: "Storage Account Key", type: "password", placeholder: "Account Key" },
|
|
139
|
+
{ key: "publicDomain", label: "\u041F\u0443\u0431\u043B\u0438\u0447\u043D\u044B\u0439 \u0434\u043E\u043C\u0435\u043D / Azure CDN URL (\u043E\u043F\u0446\u0438\u043E\u043D\u0430\u043B\u044C\u043D\u043E)", placeholder: "https://mycdn.azureedge.net" },
|
|
140
|
+
{ key: "folder", label: "\u041F\u0430\u043F\u043A\u0430 (\u043E\u043F\u0446\u0438\u043E\u043D\u0430\u043B\u044C\u043D\u043E)", placeholder: "uploads" }
|
|
141
|
+
]
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: "local",
|
|
145
|
+
name: "Local Disk",
|
|
146
|
+
description: "\u041B\u043E\u043A\u0430\u043B\u044C\u043D\u043E\u0435 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0435 \u043D\u0430 \u0434\u0438\u0441\u043A\u0435 \u0441\u0435\u0440\u0432\u0435\u0440\u0430",
|
|
147
|
+
fields: [
|
|
148
|
+
{ key: "folderPath", label: "\u041F\u0443\u0442\u044C \u043A \u0434\u0438\u0440\u0435\u043A\u0442\u043E\u0440\u0438\u0438 \u043D\u0430 \u0434\u0438\u0441\u043A\u0435", placeholder: "uploads/local", default: "uploads/local" },
|
|
149
|
+
{ key: "publicDomain", label: "\u041F\u0443\u0431\u043B\u0438\u0447\u043D\u044B\u0439 \u0431\u0430\u0437\u043E\u0432\u044B\u0439 URL (\u043E\u043F\u0446\u0438\u043E\u043D\u0430\u043B\u044C\u043D\u043E)", placeholder: "https://storage.mycompany.com" }
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
];
|
|
153
|
+
async function loadS3Sdk() {
|
|
154
|
+
try {
|
|
155
|
+
return await import("@aws-sdk/client-s3");
|
|
156
|
+
} catch {
|
|
157
|
+
throw new Error('Package "@aws-sdk/client-s3" is required for S3/R2 storage. Run "npm i @aws-sdk/client-s3".');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async function loadCloudinarySdk() {
|
|
161
|
+
try {
|
|
162
|
+
const mod = await import("cloudinary");
|
|
163
|
+
return mod.default?.v2 || mod.v2 || mod.default || mod;
|
|
164
|
+
} catch {
|
|
165
|
+
throw new Error('Package "cloudinary" is required for Cloudinary storage. Run "npm i cloudinary".');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function loadVercelBlobSdk() {
|
|
169
|
+
try {
|
|
170
|
+
return await import("@vercel/blob");
|
|
171
|
+
} catch {
|
|
172
|
+
throw new Error('Package "@vercel/blob" is required for Vercel Blob storage. Run "npm i @vercel/blob".');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function loadGcsSdk() {
|
|
176
|
+
try {
|
|
177
|
+
const mod = await import("@google-cloud/storage");
|
|
178
|
+
return mod.Storage || mod.default?.Storage || mod.default;
|
|
179
|
+
} catch {
|
|
180
|
+
throw new Error('Package "@google-cloud/storage" is required for GCS. Run "npm i @google-cloud/storage".');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function loadAzureBlobSdk() {
|
|
184
|
+
try {
|
|
185
|
+
return await import("@azure/storage-blob");
|
|
186
|
+
} catch {
|
|
187
|
+
throw new Error('Package "@azure/storage-blob" is required for Azure Blob storage. Run "npm i @azure/storage-blob".');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
class StorageManager {
|
|
191
|
+
/**
|
|
192
|
+
* @param {object} options
|
|
193
|
+
* @param {Function} options.query Database query function `(sql, params) => Promise<{ rows: any[], rowCount: number }>`
|
|
194
|
+
* @param {string} [options.storagesTable='storage_configs'] Storages table name ('storage_configs' or 'storages')
|
|
195
|
+
* @param {string} [options.filesTable='storage_files'] Files table name ('storage_files' or 'files')
|
|
196
|
+
* @param {string} [options.storageServerUrl] Remote storage-server base URL if proxying
|
|
197
|
+
* @param {string} [options.storageServerApiKey] Remote storage-server API Key
|
|
198
|
+
* @param {string} [options.publicCdnUrl] Public CDN base URL for link formatting
|
|
199
|
+
* @param {number} [options.cacheTtlMs=600000] In-memory file cache TTL (10 minutes)
|
|
200
|
+
* @param {number} [options.maxCacheEntries=5000] Maximum number of cached file metadata records
|
|
201
|
+
*/
|
|
202
|
+
constructor(options = {}) {
|
|
203
|
+
this.query = options.query;
|
|
204
|
+
this.storagesTable = options.storagesTable || "storage_configs";
|
|
205
|
+
this.filesTable = options.filesTable || "storage_files";
|
|
206
|
+
this.storageServerUrl = (options.storageServerUrl || process.env.STORAGE_SERVER_URL || "").replace(/\/+$/, "");
|
|
207
|
+
this.storageServerApiKey = options.storageServerApiKey || process.env.STORAGE_SERVER_API_KEY || "";
|
|
208
|
+
this.publicCdnUrl = (options.publicCdnUrl || "").replace(/\/+$/, "");
|
|
209
|
+
this.cacheTtlMs = options.cacheTtlMs || 10 * 60 * 1e3;
|
|
210
|
+
this.maxCacheEntries = options.maxCacheEntries || 5e3;
|
|
211
|
+
this.fileMetadataCache = /* @__PURE__ */ new Map();
|
|
212
|
+
this.activeStorageCache = { data: null, ts: 0 };
|
|
213
|
+
this.storageCacheTtlMs = 3e4;
|
|
214
|
+
}
|
|
215
|
+
getProviders() {
|
|
216
|
+
return STORAGE_PROVIDERS;
|
|
217
|
+
}
|
|
218
|
+
invalidateFileCache(fileId) {
|
|
219
|
+
if (fileId) this.fileMetadataCache.delete(fileId);
|
|
220
|
+
}
|
|
221
|
+
invalidateStorageCache() {
|
|
222
|
+
this.activeStorageCache = { data: null, ts: 0 };
|
|
223
|
+
}
|
|
224
|
+
async getCachedFileRecord(fileId) {
|
|
225
|
+
if (!fileId) return null;
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
const cached = this.fileMetadataCache.get(fileId);
|
|
228
|
+
if (cached && now - cached.ts < this.cacheTtlMs) {
|
|
229
|
+
return cached.data;
|
|
230
|
+
}
|
|
231
|
+
if (!this.query) return null;
|
|
232
|
+
const r = await this.query(`SELECT * FROM ${this.filesTable} WHERE id = $1`, [fileId]);
|
|
233
|
+
if (r.rows.length === 0) return null;
|
|
234
|
+
const file = r.rows[0];
|
|
235
|
+
if (this.fileMetadataCache.size >= this.maxCacheEntries) {
|
|
236
|
+
const firstKey = this.fileMetadataCache.keys().next().value;
|
|
237
|
+
this.fileMetadataCache.delete(firstKey);
|
|
238
|
+
}
|
|
239
|
+
this.fileMetadataCache.set(fileId, { data: file, ts: now });
|
|
240
|
+
return file;
|
|
241
|
+
}
|
|
242
|
+
formatFileRecord(f, reqBaseUrl = "") {
|
|
243
|
+
if (!f) return null;
|
|
244
|
+
const fileId = f.id;
|
|
245
|
+
const mimeType = f.mime_type || "application/octet-stream";
|
|
246
|
+
const fileType = f.file_type || (0, import_storage.getFileTypeCategory)(mimeType, f.original_name);
|
|
247
|
+
const base = this.publicCdnUrl || reqBaseUrl || "";
|
|
248
|
+
return {
|
|
249
|
+
id: fileId,
|
|
250
|
+
storageId: f.storage_id || null,
|
|
251
|
+
apiKeyId: f.api_key_id || null,
|
|
252
|
+
originalName: f.original_name || "file",
|
|
253
|
+
fileType,
|
|
254
|
+
mimeType,
|
|
255
|
+
fileSize: Number(f.file_size || 0),
|
|
256
|
+
cloudUrl: f.cloud_url,
|
|
257
|
+
viewUrl: `${base}/cdn/${fileId}/view`,
|
|
258
|
+
downloadUrl: `${base}/cdn/${fileId}/download`,
|
|
259
|
+
url: `${base}/cdn/${fileId}`,
|
|
260
|
+
entityType: f.entity_type || "general",
|
|
261
|
+
entityId: f.entity_id || f.inspection_id || null,
|
|
262
|
+
viewCount: Number(f.view_count || 0),
|
|
263
|
+
createdAt: f.created_at,
|
|
264
|
+
updatedAt: f.updated_at
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
async getAvailableStorage(fileSize = 0) {
|
|
268
|
+
if (!this.query) throw new Error("[cynx/storage] Database query function is required to resolve available storage.");
|
|
269
|
+
const now = Date.now();
|
|
270
|
+
let activeStorages = this.activeStorageCache.data;
|
|
271
|
+
if (!activeStorages || now - this.activeStorageCache.ts > this.storageCacheTtlMs) {
|
|
272
|
+
const res = await this.query(
|
|
273
|
+
`SELECT * FROM ${this.storagesTable} WHERE is_active = true ORDER BY sort_order ASC, created_at DESC`
|
|
274
|
+
);
|
|
275
|
+
activeStorages = res.rows;
|
|
276
|
+
this.activeStorageCache = { data: activeStorages, ts: now };
|
|
277
|
+
}
|
|
278
|
+
for (const storage of activeStorages) {
|
|
279
|
+
const maxBytes = BigInt(storage.max_bytes || "0");
|
|
280
|
+
const usedBytes = BigInt(storage.used_bytes || "0");
|
|
281
|
+
const needed = BigInt(fileSize);
|
|
282
|
+
if (usedBytes >= maxBytes) {
|
|
283
|
+
await this.query(
|
|
284
|
+
`UPDATE ${this.storagesTable} SET is_active = false, updated_at = NOW() WHERE id = $1`,
|
|
285
|
+
[storage.id]
|
|
286
|
+
);
|
|
287
|
+
this.invalidateStorageCache();
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (usedBytes + needed <= maxBytes) {
|
|
291
|
+
return storage;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
throw new Error("\u041D\u0435\u0442 \u0430\u043A\u0442\u0438\u0432\u043D\u043E\u0433\u043E \u043E\u0431\u043B\u0430\u0447\u043D\u043E\u0433\u043E \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0430 \u0441 \u0434\u043E\u0441\u0442\u0430\u0442\u043E\u0447\u043D\u044B\u043C \u0441\u0432\u043E\u0431\u043E\u0434\u043D\u044B\u043C \u043C\u0435\u0441\u0442\u043E\u043C");
|
|
295
|
+
}
|
|
296
|
+
async updateStorageUsage(storageId, byteDelta) {
|
|
297
|
+
if (!storageId || !this.query) return;
|
|
298
|
+
const delta = BigInt(byteDelta);
|
|
299
|
+
const current = await this.query(`SELECT used_bytes, max_bytes FROM ${this.storagesTable} WHERE id = $1`, [storageId]);
|
|
300
|
+
if (current.rows.length === 0) return;
|
|
301
|
+
let newUsed = BigInt(current.rows[0].used_bytes || "0") + delta;
|
|
302
|
+
if (newUsed < 0n) newUsed = 0n;
|
|
303
|
+
const maxBytes = BigInt(current.rows[0].max_bytes || "0");
|
|
304
|
+
const isFull = newUsed >= maxBytes;
|
|
305
|
+
await this.query(
|
|
306
|
+
`UPDATE ${this.storagesTable} SET used_bytes = $1, is_active = CASE WHEN $2 THEN false ELSE is_active END, updated_at = NOW() WHERE id = $3`,
|
|
307
|
+
[newUsed.toString(), isFull, storageId]
|
|
308
|
+
);
|
|
309
|
+
this.invalidateStorageCache();
|
|
310
|
+
}
|
|
311
|
+
_resolveFolderPath(configFolder, customFolder) {
|
|
312
|
+
const parts = [];
|
|
313
|
+
if (configFolder && typeof configFolder === "string" && configFolder.trim()) {
|
|
314
|
+
parts.push(configFolder.trim().replace(/^\/+|\/+$/g, ""));
|
|
315
|
+
}
|
|
316
|
+
if (customFolder && typeof customFolder === "string" && customFolder.trim()) {
|
|
317
|
+
const cleanCustom = customFolder.trim().replace(/^\/+|\/+$/g, "");
|
|
318
|
+
if (!parts[0] || !cleanCustom.startsWith(parts[0])) {
|
|
319
|
+
parts.push(cleanCustom);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return parts.filter(Boolean).join("/");
|
|
323
|
+
}
|
|
324
|
+
async _getS3Client(storageConfig) {
|
|
325
|
+
const { S3Client } = await loadS3Sdk();
|
|
326
|
+
const { provider, credentials } = storageConfig;
|
|
327
|
+
if (provider === "r2") {
|
|
328
|
+
const { accountId, accessKeyId, secretAccessKey } = credentials || {};
|
|
329
|
+
if (!accountId || !accessKeyId || !secretAccessKey) {
|
|
330
|
+
throw new Error("\u041D\u0435\u043F\u043E\u043B\u043D\u044B\u0435 \u0434\u0430\u043D\u043D\u044B\u0435 \u043F\u043E\u0434\u043A\u043B\u044E\u0447\u0435\u043D\u0438\u044F Cloudflare R2 (Account ID, Access Key ID, Secret Access Key)");
|
|
331
|
+
}
|
|
332
|
+
return new S3Client({
|
|
333
|
+
region: "auto",
|
|
334
|
+
endpoint: `https://${accountId.trim()}.r2.cloudflarestorage.com`,
|
|
335
|
+
credentials: {
|
|
336
|
+
accessKeyId: accessKeyId.trim(),
|
|
337
|
+
secretAccessKey: secretAccessKey.trim()
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
} else if (provider === "s3") {
|
|
341
|
+
const { region, accessKeyId, secretAccessKey, endpoint: endpoint2 } = credentials || {};
|
|
342
|
+
if (!region || !accessKeyId || !secretAccessKey) {
|
|
343
|
+
throw new Error("\u041D\u0435\u043F\u043E\u043B\u043D\u044B\u0435 \u0434\u0430\u043D\u043D\u044B\u0435 \u043F\u043E\u0434\u043A\u043B\u044E\u0447\u0435\u043D\u0438\u044F Amazon S3 (Region, Access Key ID, Secret Access Key)");
|
|
344
|
+
}
|
|
345
|
+
const reg = region && region.trim() && region.trim().toLowerCase() !== "auto" ? region.trim() : "us-east-1";
|
|
346
|
+
const clientOpts = {
|
|
347
|
+
region: reg,
|
|
348
|
+
credentials: {
|
|
349
|
+
accessKeyId: accessKeyId.trim(),
|
|
350
|
+
secretAccessKey: secretAccessKey.trim()
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
if (endpoint2 && endpoint2.trim()) {
|
|
354
|
+
let cleanEndpoint = endpoint2.trim();
|
|
355
|
+
if (!/^https?:\/\//i.test(cleanEndpoint)) {
|
|
356
|
+
cleanEndpoint = `https://${cleanEndpoint}`;
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
const parsed = new URL(cleanEndpoint);
|
|
360
|
+
cleanEndpoint = parsed.origin;
|
|
361
|
+
} catch {
|
|
362
|
+
cleanEndpoint = cleanEndpoint.replace(/\/+$/, "");
|
|
363
|
+
}
|
|
364
|
+
clientOpts.endpoint = cleanEndpoint;
|
|
365
|
+
clientOpts.forcePathStyle = true;
|
|
366
|
+
}
|
|
367
|
+
return new S3Client(clientOpts);
|
|
368
|
+
}
|
|
369
|
+
throw new Error(`\u041F\u0440\u043E\u0432\u0430\u0439\u0434\u0435\u0440 ${provider} \u043D\u0435 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044F \u0434\u043B\u044F S3-\u043A\u043B\u0438\u0435\u043D\u0442\u0430`);
|
|
370
|
+
}
|
|
371
|
+
async _getGcsClient(storageConfig) {
|
|
372
|
+
const GoogleCloudStorage = await loadGcsSdk();
|
|
373
|
+
const { credentials } = storageConfig;
|
|
374
|
+
const { projectId, clientEmail, privateKey } = credentials || {};
|
|
375
|
+
const opts = {};
|
|
376
|
+
if (projectId && projectId.trim()) opts.projectId = projectId.trim();
|
|
377
|
+
if (clientEmail && privateKey) {
|
|
378
|
+
opts.credentials = {
|
|
379
|
+
client_email: clientEmail.trim(),
|
|
380
|
+
private_key: privateKey.replace(/\\n/g, "\n")
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
return new GoogleCloudStorage(opts);
|
|
384
|
+
}
|
|
385
|
+
async _getAzureBlobClient(storageConfig) {
|
|
386
|
+
const { BlobServiceClient, StorageSharedKeyCredential } = await loadAzureBlobSdk();
|
|
387
|
+
const { credentials } = storageConfig;
|
|
388
|
+
const { connectionString, accountName, accountKey } = credentials || {};
|
|
389
|
+
if (connectionString && connectionString.trim()) {
|
|
390
|
+
return BlobServiceClient.fromConnectionString(connectionString.trim());
|
|
391
|
+
}
|
|
392
|
+
if (accountName && accountKey) {
|
|
393
|
+
const sharedKey = new StorageSharedKeyCredential(accountName.trim(), accountKey.trim());
|
|
394
|
+
return new BlobServiceClient(`https://${accountName.trim()}.blob.core.windows.net`, sharedKey);
|
|
395
|
+
}
|
|
396
|
+
throw new Error("\u041D\u0435\u043F\u043E\u043B\u043D\u044B\u0435 \u0434\u0430\u043D\u043D\u044B\u0435 \u043F\u043E\u0434\u043A\u043B\u044E\u0447\u0435\u043D\u0438\u044F Azure Blob (Connection String \u0438\u043B\u0438 Account Name + Account Key)");
|
|
397
|
+
}
|
|
398
|
+
// ─── UPLOAD TO PROVIDER ────────────────────────────────────────────────────────
|
|
399
|
+
async uploadToFileStorage(storageConfig, fileBuffer, originalName, mimeType, customFolder = null) {
|
|
400
|
+
const { provider, credentials } = storageConfig;
|
|
401
|
+
const safeName = originalName ? originalName.replace(/[^a-zA-Z0-9_.-]/g, "_") : "file";
|
|
402
|
+
if (provider === "local") {
|
|
403
|
+
const basePath = credentials?.folderPath?.trim() || "uploads/local";
|
|
404
|
+
const folderPath = this._resolveFolderPath(basePath, customFolder);
|
|
405
|
+
const fullDirPath = import_path.default.resolve(process.cwd(), folderPath);
|
|
406
|
+
await import_fs.default.promises.mkdir(fullDirPath, { recursive: true });
|
|
407
|
+
const fileName = `${(0, import_uid.genUid)()}_${Date.now()}_${safeName}`;
|
|
408
|
+
const fullFilePath = import_path.default.join(fullDirPath, fileName);
|
|
409
|
+
const relativeKey = `${folderPath}/${fileName}`;
|
|
410
|
+
await import_fs.default.promises.writeFile(fullFilePath, fileBuffer);
|
|
411
|
+
let cloudUrl = "";
|
|
412
|
+
if (credentials?.publicDomain && credentials.publicDomain.trim()) {
|
|
413
|
+
const base = credentials.publicDomain.trim().replace(/\/+$/, "");
|
|
414
|
+
cloudUrl = `${base}/${relativeKey}`;
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
cloudUrl: cloudUrl || relativeKey,
|
|
418
|
+
cloudPublicId: relativeKey,
|
|
419
|
+
resourceType: "local",
|
|
420
|
+
bytes: fileBuffer.length
|
|
421
|
+
};
|
|
422
|
+
} else if (provider === "gcs") {
|
|
423
|
+
const { bucket, folder, publicDomain } = credentials || {};
|
|
424
|
+
if (!bucket) throw new Error("\u041D\u0435 \u0443\u043A\u0430\u0437\u0430\u043D\u043E \u0438\u043C\u044F \u0431\u0430\u043A\u0435\u0442\u0430 GCS (Bucket Name)");
|
|
425
|
+
const gcs = await this._getGcsClient(storageConfig);
|
|
426
|
+
const gcsBucket = gcs.bucket(bucket.trim());
|
|
427
|
+
const folderPath = this._resolveFolderPath(folder, customFolder);
|
|
428
|
+
const objectKey = `${folderPath ? folderPath + "/" : ""}${(0, import_uid.genUid)()}_${Date.now()}_${safeName}`;
|
|
429
|
+
const gcsFile = gcsBucket.file(objectKey);
|
|
430
|
+
await gcsFile.save(fileBuffer, {
|
|
431
|
+
contentType: mimeType || "application/octet-stream",
|
|
432
|
+
resumable: false
|
|
433
|
+
});
|
|
434
|
+
let cloudUrl = "";
|
|
435
|
+
if (publicDomain && publicDomain.trim()) {
|
|
436
|
+
let base = publicDomain.trim().replace(/\/+$/, "");
|
|
437
|
+
if (!/^https?:\/\//i.test(base)) base = `https://${base}`;
|
|
438
|
+
cloudUrl = `${base}/${objectKey}`;
|
|
439
|
+
} else {
|
|
440
|
+
cloudUrl = `https://storage.googleapis.com/${bucket.trim()}/${objectKey}`;
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
cloudUrl,
|
|
444
|
+
cloudPublicId: objectKey,
|
|
445
|
+
resourceType: "gcs",
|
|
446
|
+
bytes: fileBuffer.length
|
|
447
|
+
};
|
|
448
|
+
} else if (provider === "azure_blob") {
|
|
449
|
+
const { containerName, folder, publicDomain } = credentials || {};
|
|
450
|
+
if (!containerName) throw new Error("\u041D\u0435 \u0443\u043A\u0430\u0437\u0430\u043D\u043E \u0438\u043C\u044F \u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u0430 Azure (Container Name)");
|
|
451
|
+
const blobService = await this._getAzureBlobClient(storageConfig);
|
|
452
|
+
const containerClient = blobService.getContainerClient(containerName.trim());
|
|
453
|
+
await containerClient.createIfNotExists();
|
|
454
|
+
const folderPath = this._resolveFolderPath(folder, customFolder);
|
|
455
|
+
const objectKey = `${folderPath ? folderPath + "/" : ""}${(0, import_uid.genUid)()}_${Date.now()}_${safeName}`;
|
|
456
|
+
const blockBlobClient = containerClient.getBlockBlobClient(objectKey);
|
|
457
|
+
await blockBlobClient.uploadData(fileBuffer, {
|
|
458
|
+
blobHTTPHeaders: {
|
|
459
|
+
blobContentType: mimeType || "application/octet-stream"
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
let cloudUrl = "";
|
|
463
|
+
if (publicDomain && publicDomain.trim()) {
|
|
464
|
+
let base = publicDomain.trim().replace(/\/+$/, "");
|
|
465
|
+
if (!/^https?:\/\//i.test(base)) base = `https://${base}`;
|
|
466
|
+
cloudUrl = `${base}/${objectKey}`;
|
|
467
|
+
} else {
|
|
468
|
+
cloudUrl = blockBlobClient.url;
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
cloudUrl,
|
|
472
|
+
cloudPublicId: objectKey,
|
|
473
|
+
resourceType: "azure_blob",
|
|
474
|
+
bytes: fileBuffer.length
|
|
475
|
+
};
|
|
476
|
+
} else if (provider === "supabase") {
|
|
477
|
+
const { projectUrl, serviceKey, bucket, folder, publicDomain } = credentials || {};
|
|
478
|
+
if (!projectUrl || !serviceKey || !bucket) {
|
|
479
|
+
throw new Error("\u041D\u0435\u043F\u043E\u043B\u043D\u044B\u0435 \u0434\u0430\u043D\u043D\u044B\u0435 \u043F\u043E\u0434\u043A\u043B\u044E\u0447\u0435\u043D\u0438\u044F Supabase (Project URL, Service Key, Bucket Name)");
|
|
480
|
+
}
|
|
481
|
+
const folderPath = this._resolveFolderPath(folder, customFolder);
|
|
482
|
+
const objectKey = `${folderPath ? folderPath + "/" : ""}${(0, import_uid.genUid)()}_${Date.now()}_${safeName}`;
|
|
483
|
+
const cleanUrl = projectUrl.trim().replace(/\/+$/, "");
|
|
484
|
+
const uploadUrl = new URL(`${cleanUrl}/storage/v1/object/${encodeURIComponent(bucket.trim())}/${objectKey}`);
|
|
485
|
+
const isHttps = uploadUrl.protocol === "https:";
|
|
486
|
+
const client = isHttps ? import_https.default : import_http.default;
|
|
487
|
+
const agent = isHttps ? httpsAgent : httpAgent;
|
|
488
|
+
await new Promise((resolve, reject) => {
|
|
489
|
+
const req = client.request(
|
|
490
|
+
uploadUrl,
|
|
491
|
+
{
|
|
492
|
+
method: "POST",
|
|
493
|
+
agent,
|
|
494
|
+
headers: {
|
|
495
|
+
Authorization: `Bearer ${serviceKey.trim()}`,
|
|
496
|
+
"Content-Type": mimeType || "application/octet-stream",
|
|
497
|
+
"Content-Length": fileBuffer.length,
|
|
498
|
+
"x-upsert": "true"
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
(res) => {
|
|
502
|
+
if (res.statusCode >= 200 && res.statusCode < 300) return resolve();
|
|
503
|
+
let errData = "";
|
|
504
|
+
res.on("data", (c) => errData += c);
|
|
505
|
+
res.on("end", () => reject(new Error(`Supabase Upload Error (${res.statusCode}): ${errData}`)));
|
|
506
|
+
}
|
|
507
|
+
);
|
|
508
|
+
req.on("error", reject);
|
|
509
|
+
req.write(fileBuffer);
|
|
510
|
+
req.end();
|
|
511
|
+
});
|
|
512
|
+
let cloudUrl = "";
|
|
513
|
+
if (publicDomain && publicDomain.trim()) {
|
|
514
|
+
let base = publicDomain.trim().replace(/\/+$/, "");
|
|
515
|
+
if (!/^https?:\/\//i.test(base)) base = `https://${base}`;
|
|
516
|
+
cloudUrl = `${base}/${objectKey}`;
|
|
517
|
+
} else {
|
|
518
|
+
cloudUrl = `${cleanUrl}/storage/v1/object/public/${encodeURIComponent(bucket.trim())}/${objectKey}`;
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
cloudUrl,
|
|
522
|
+
cloudPublicId: objectKey,
|
|
523
|
+
resourceType: "supabase",
|
|
524
|
+
bytes: fileBuffer.length
|
|
525
|
+
};
|
|
526
|
+
} else if (provider === "cloudinary") {
|
|
527
|
+
const { cloudName, apiKey, apiSecret, folder } = credentials || {};
|
|
528
|
+
if (!cloudName || !apiKey || !apiSecret) {
|
|
529
|
+
throw new Error("\u041D\u0435\u043F\u043E\u043B\u043D\u044B\u0435 \u0434\u0430\u043D\u043D\u044B\u0435 \u043F\u043E\u0434\u043A\u043B\u044E\u0447\u0435\u043D\u0438\u044F Cloudinary (Cloud Name, API Key, API Secret)");
|
|
530
|
+
}
|
|
531
|
+
const cloudinary = await loadCloudinarySdk();
|
|
532
|
+
cloudinary.config({
|
|
533
|
+
cloud_name: cloudName,
|
|
534
|
+
api_key: apiKey,
|
|
535
|
+
api_secret: apiSecret,
|
|
536
|
+
secure: true
|
|
537
|
+
});
|
|
538
|
+
const isRaw = !mimeType.startsWith("image/");
|
|
539
|
+
const resourceType = isRaw ? "raw" : "image";
|
|
540
|
+
const uploadFolder = this._resolveFolderPath(folder, customFolder) || "emp_storage";
|
|
541
|
+
const uniquePublicId = `${(0, import_uid.genUid)()}_${Date.now()}_${safeName}`;
|
|
542
|
+
const result = await new Promise((resolve, reject) => {
|
|
543
|
+
const uploadStream = cloudinary.uploader.upload_stream(
|
|
544
|
+
{
|
|
545
|
+
folder: uploadFolder,
|
|
546
|
+
resource_type: resourceType,
|
|
547
|
+
public_id: uniquePublicId
|
|
548
|
+
},
|
|
549
|
+
(error, res) => {
|
|
550
|
+
if (error) return reject(error);
|
|
551
|
+
resolve(res);
|
|
552
|
+
}
|
|
553
|
+
);
|
|
554
|
+
uploadStream.end(fileBuffer);
|
|
555
|
+
});
|
|
556
|
+
return {
|
|
557
|
+
cloudUrl: result.secure_url,
|
|
558
|
+
cloudPublicId: result.public_id,
|
|
559
|
+
resourceType,
|
|
560
|
+
bytes: result.bytes || fileBuffer.length
|
|
561
|
+
};
|
|
562
|
+
} else if (provider === "vercel_blob") {
|
|
563
|
+
const { readWriteToken, folder } = credentials || {};
|
|
564
|
+
if (!readWriteToken) {
|
|
565
|
+
throw new Error("\u041E\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 Read/Write Token \u0434\u043B\u044F Vercel Blob");
|
|
566
|
+
}
|
|
567
|
+
const { put } = await loadVercelBlobSdk();
|
|
568
|
+
const folderPath = this._resolveFolderPath(folder, customFolder) || "emp-storage";
|
|
569
|
+
const pathKey = `${folderPath}/${(0, import_uid.genUid)()}_${Date.now()}_${safeName}`;
|
|
570
|
+
const blobResult = await put(pathKey, fileBuffer, {
|
|
571
|
+
access: "public",
|
|
572
|
+
token: readWriteToken,
|
|
573
|
+
contentType: mimeType || "application/octet-stream"
|
|
574
|
+
});
|
|
575
|
+
return {
|
|
576
|
+
cloudUrl: blobResult.url,
|
|
577
|
+
cloudPublicId: blobResult.url,
|
|
578
|
+
resourceType: "blob",
|
|
579
|
+
bytes: fileBuffer.length
|
|
580
|
+
};
|
|
581
|
+
} else if (provider === "s3" || provider === "r2") {
|
|
582
|
+
const { bucket, folder, publicDomain, region } = credentials || {};
|
|
583
|
+
if (!bucket) {
|
|
584
|
+
throw new Error(`\u041D\u0435 \u0443\u043A\u0430\u0437\u0430\u043D\u043E \u0438\u043C\u044F \u0431\u0430\u043A\u0435\u0442\u0430 (Bucket Name) \u0434\u043B\u044F ${provider === "r2" ? "Cloudflare R2" : "Amazon S3"}`);
|
|
585
|
+
}
|
|
586
|
+
const { PutObjectCommand } = await loadS3Sdk();
|
|
587
|
+
const s3 = await this._getS3Client(storageConfig);
|
|
588
|
+
const folderPath = this._resolveFolderPath(folder, customFolder);
|
|
589
|
+
const objectKey = `${folderPath ? folderPath + "/" : ""}${(0, import_uid.genUid)()}_${Date.now()}_${safeName}`;
|
|
590
|
+
await s3.send(
|
|
591
|
+
new PutObjectCommand({
|
|
592
|
+
Bucket: bucket.trim(),
|
|
593
|
+
Key: objectKey,
|
|
594
|
+
Body: fileBuffer,
|
|
595
|
+
ContentType: mimeType || "application/octet-stream",
|
|
596
|
+
ContentLength: fileBuffer.length
|
|
597
|
+
})
|
|
598
|
+
);
|
|
599
|
+
let cloudUrl = "";
|
|
600
|
+
if (publicDomain && publicDomain.trim()) {
|
|
601
|
+
let base = publicDomain.trim().replace(/\/+$/, "");
|
|
602
|
+
if (!/^https?:\/\//i.test(base)) base = `https://${base}`;
|
|
603
|
+
cloudUrl = `${base}/${objectKey}`;
|
|
604
|
+
} else if (endpoint && endpoint.trim()) {
|
|
605
|
+
let base = endpoint.trim().replace(/\/+$/, "");
|
|
606
|
+
if (!/^https?:\/\//i.test(base)) base = `https://${base}`;
|
|
607
|
+
cloudUrl = `${base}/${bucket.trim()}/${objectKey}`;
|
|
608
|
+
} else if (provider === "s3") {
|
|
609
|
+
const reg = region ? region.trim() : "us-east-1";
|
|
610
|
+
cloudUrl = `https://${bucket.trim()}.s3.${reg}.amazonaws.com/${objectKey}`;
|
|
611
|
+
} else {
|
|
612
|
+
cloudUrl = `https://${bucket.trim()}.${credentials.accountId?.trim() || ""}.r2.cloudflarestorage.com/${objectKey}`;
|
|
613
|
+
}
|
|
614
|
+
return {
|
|
615
|
+
cloudUrl,
|
|
616
|
+
cloudPublicId: objectKey,
|
|
617
|
+
resourceType: "s3",
|
|
618
|
+
bytes: fileBuffer.length
|
|
619
|
+
};
|
|
620
|
+
} else {
|
|
621
|
+
throw new Error(`\u041D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u044B\u0439 \u043F\u0440\u043E\u0432\u0430\u0439\u0434\u0435\u0440 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0430: ${provider}`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
// ─── DELETE FROM PROVIDER ──────────────────────────────────────────────────────
|
|
625
|
+
async deleteFromFileStorage(storageConfig, cloudPublicId, cloudUrl, mimeType = "") {
|
|
626
|
+
if (!storageConfig) return;
|
|
627
|
+
const { provider, credentials } = storageConfig;
|
|
628
|
+
try {
|
|
629
|
+
if (provider === "local") {
|
|
630
|
+
if (cloudPublicId) {
|
|
631
|
+
const fullPath = import_path.default.resolve(process.cwd(), cloudPublicId);
|
|
632
|
+
if (import_fs.default.existsSync(fullPath)) {
|
|
633
|
+
await import_fs.default.promises.unlink(fullPath);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
} else if (provider === "gcs") {
|
|
637
|
+
const { bucket } = credentials || {};
|
|
638
|
+
if (bucket && cloudPublicId) {
|
|
639
|
+
const gcs = await this._getGcsClient(storageConfig);
|
|
640
|
+
await gcs.bucket(bucket.trim()).file(cloudPublicId).delete({ ignoreNotFound: true });
|
|
641
|
+
}
|
|
642
|
+
} else if (provider === "azure_blob") {
|
|
643
|
+
const { containerName } = credentials || {};
|
|
644
|
+
if (containerName && cloudPublicId) {
|
|
645
|
+
const blobService = await this._getAzureBlobClient(storageConfig);
|
|
646
|
+
await blobService.getContainerClient(containerName.trim()).getBlockBlobClient(cloudPublicId).deleteIfExists();
|
|
647
|
+
}
|
|
648
|
+
} else if (provider === "supabase") {
|
|
649
|
+
const { projectUrl, serviceKey, bucket } = credentials || {};
|
|
650
|
+
if (projectUrl && serviceKey && bucket && cloudPublicId) {
|
|
651
|
+
const cleanUrl = projectUrl.trim().replace(/\/+$/, "");
|
|
652
|
+
const deleteUrl = new URL(`${cleanUrl}/storage/v1/object/${encodeURIComponent(bucket.trim())}`);
|
|
653
|
+
const isHttps = deleteUrl.protocol === "https:";
|
|
654
|
+
const client = isHttps ? import_https.default : import_http.default;
|
|
655
|
+
const agent = isHttps ? httpsAgent : httpAgent;
|
|
656
|
+
const payload = JSON.stringify({ prefixes: [cloudPublicId] });
|
|
657
|
+
await new Promise((resolve) => {
|
|
658
|
+
const req = client.request(
|
|
659
|
+
deleteUrl,
|
|
660
|
+
{
|
|
661
|
+
method: "DELETE",
|
|
662
|
+
agent,
|
|
663
|
+
headers: {
|
|
664
|
+
Authorization: `Bearer ${serviceKey.trim()}`,
|
|
665
|
+
"Content-Type": "application/json",
|
|
666
|
+
"Content-Length": Buffer.byteLength(payload)
|
|
667
|
+
}
|
|
668
|
+
},
|
|
669
|
+
() => resolve()
|
|
670
|
+
);
|
|
671
|
+
req.on("error", () => resolve());
|
|
672
|
+
req.write(payload);
|
|
673
|
+
req.end();
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
} else if (provider === "cloudinary") {
|
|
677
|
+
const { cloudName, apiKey, apiSecret } = credentials || {};
|
|
678
|
+
if (!cloudName || !apiKey || !apiSecret) return;
|
|
679
|
+
const cloudinary = await loadCloudinarySdk();
|
|
680
|
+
cloudinary.config({
|
|
681
|
+
cloud_name: cloudName,
|
|
682
|
+
api_key: apiKey,
|
|
683
|
+
api_secret: apiSecret,
|
|
684
|
+
secure: true
|
|
685
|
+
});
|
|
686
|
+
const isRaw = mimeType && !mimeType.startsWith("image/");
|
|
687
|
+
await cloudinary.uploader.destroy(cloudPublicId, {
|
|
688
|
+
resource_type: isRaw ? "raw" : "image"
|
|
689
|
+
});
|
|
690
|
+
} else if (provider === "vercel_blob") {
|
|
691
|
+
const { readWriteToken } = credentials || {};
|
|
692
|
+
if (!readWriteToken) return;
|
|
693
|
+
const { del } = await loadVercelBlobSdk();
|
|
694
|
+
await del(cloudUrl || cloudPublicId, { token: readWriteToken });
|
|
695
|
+
} else if (provider === "s3" || provider === "r2") {
|
|
696
|
+
const { bucket } = credentials || {};
|
|
697
|
+
if (!bucket || !cloudPublicId) return;
|
|
698
|
+
const { DeleteObjectCommand } = await loadS3Sdk();
|
|
699
|
+
const s3 = await this._getS3Client(storageConfig);
|
|
700
|
+
await s3.send(
|
|
701
|
+
new DeleteObjectCommand({
|
|
702
|
+
Bucket: bucket.trim(),
|
|
703
|
+
Key: cloudPublicId
|
|
704
|
+
})
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
} catch (err) {
|
|
708
|
+
console.error(`[cynx/storage] Failed to delete file from ${provider}:`, err.message);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
async testStorageConnection(storageConfig) {
|
|
712
|
+
const testBuffer = Buffer.from("CYNX_STORAGE_CONNECTION_TEST");
|
|
713
|
+
const uploaded = await this.uploadToFileStorage(
|
|
714
|
+
storageConfig,
|
|
715
|
+
testBuffer,
|
|
716
|
+
"test_connection.txt",
|
|
717
|
+
"text/plain",
|
|
718
|
+
"test"
|
|
719
|
+
);
|
|
720
|
+
await this.deleteFromFileStorage(
|
|
721
|
+
storageConfig,
|
|
722
|
+
uploaded.cloudPublicId,
|
|
723
|
+
uploaded.cloudUrl,
|
|
724
|
+
"text/plain"
|
|
725
|
+
);
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
// ─── STREAMING & SERVE ────────────────────────────────────────────────────────
|
|
729
|
+
async _streamFromS3(file, req, res, isInline) {
|
|
730
|
+
try {
|
|
731
|
+
const storageRes = await this.query(`SELECT * FROM ${this.storagesTable} WHERE id = $1`, [file.storage_id]);
|
|
732
|
+
const storageConfig = storageRes.rows[0];
|
|
733
|
+
if (!storageConfig || storageConfig.provider !== "s3" && storageConfig.provider !== "r2") {
|
|
734
|
+
return res.status(404).send("Storage not found");
|
|
735
|
+
}
|
|
736
|
+
const { bucket } = storageConfig.credentials || {};
|
|
737
|
+
if (!bucket) return res.status(500).send("Bucket name missing in storage configuration");
|
|
738
|
+
const { GetObjectCommand } = await loadS3Sdk();
|
|
739
|
+
const s3 = await this._getS3Client(storageConfig);
|
|
740
|
+
const objectKey = file.cloud_public_id || file.cloud_url?.split("/").slice(3).join("/");
|
|
741
|
+
const commandParams = {
|
|
742
|
+
Bucket: bucket.trim(),
|
|
743
|
+
Key: objectKey
|
|
744
|
+
};
|
|
745
|
+
if (req?.headers && req.headers["range"]) {
|
|
746
|
+
commandParams.Range = req.headers["range"];
|
|
747
|
+
}
|
|
748
|
+
const s3Res = await s3.send(new GetObjectCommand(commandParams));
|
|
749
|
+
const etag = `"${file.id}-${file.file_size || "0"}"`;
|
|
750
|
+
const filename = file.original_name || "file";
|
|
751
|
+
const safeFilename = filename.replace(/["\r\n]/g, "_");
|
|
752
|
+
const encodedFilename = encodeURIComponent(filename);
|
|
753
|
+
const disposition = isInline ? "inline" : "attachment";
|
|
754
|
+
res.setHeader("ETag", etag);
|
|
755
|
+
res.setHeader("Content-Type", s3Res.ContentType || file.mime_type || "application/octet-stream");
|
|
756
|
+
res.setHeader("Accept-Ranges", "bytes");
|
|
757
|
+
res.setHeader(
|
|
758
|
+
"Content-Disposition",
|
|
759
|
+
`${disposition}; filename="${safeFilename}"; filename*=UTF-8''${encodedFilename}`
|
|
760
|
+
);
|
|
761
|
+
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
762
|
+
if (s3Res.ContentRange) {
|
|
763
|
+
res.setHeader("Content-Range", s3Res.ContentRange);
|
|
764
|
+
res.status(206);
|
|
765
|
+
} else {
|
|
766
|
+
res.status(200);
|
|
767
|
+
}
|
|
768
|
+
if (s3Res.ContentLength) {
|
|
769
|
+
res.setHeader("Content-Length", s3Res.ContentLength);
|
|
770
|
+
}
|
|
771
|
+
s3Res.Body.pipe(res);
|
|
772
|
+
} catch (err) {
|
|
773
|
+
console.error("[cynx/storage] S3/R2 stream error:", err.message);
|
|
774
|
+
if (!res.headersSent) {
|
|
775
|
+
res.status(500).send("Error streaming file from storage");
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
_streamFromLocal(file, req, res, isInline) {
|
|
780
|
+
const filePath = import_path.default.resolve(process.cwd(), file.cloud_public_id);
|
|
781
|
+
if (!import_fs.default.existsSync(filePath)) {
|
|
782
|
+
return res.status(404).send("Local file not found on disk");
|
|
783
|
+
}
|
|
784
|
+
const stat = import_fs.default.statSync(filePath);
|
|
785
|
+
const etag = `"${file.id}-${stat.size}"`;
|
|
786
|
+
const ifNoneMatch = req?.headers ? req.headers["if-none-match"] : null;
|
|
787
|
+
if (ifNoneMatch && (ifNoneMatch === etag || ifNoneMatch === `W/${etag}`)) {
|
|
788
|
+
return res.status(304).end();
|
|
789
|
+
}
|
|
790
|
+
const filename = file.original_name || "file";
|
|
791
|
+
const safeFilename = filename.replace(/["\r\n]/g, "_");
|
|
792
|
+
const encodedFilename = encodeURIComponent(filename);
|
|
793
|
+
const disposition = isInline ? "inline" : "attachment";
|
|
794
|
+
res.setHeader("ETag", etag);
|
|
795
|
+
res.setHeader("Content-Type", file.mime_type || "application/octet-stream");
|
|
796
|
+
res.setHeader("Accept-Ranges", "bytes");
|
|
797
|
+
res.setHeader(
|
|
798
|
+
"Content-Disposition",
|
|
799
|
+
`${disposition}; filename="${safeFilename}"; filename*=UTF-8''${encodedFilename}`
|
|
800
|
+
);
|
|
801
|
+
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
802
|
+
const range = req?.headers ? req.headers["range"] : null;
|
|
803
|
+
if (range) {
|
|
804
|
+
const parts = range.replace(/bytes=/, "").split("-");
|
|
805
|
+
const start = parseInt(parts[0], 10);
|
|
806
|
+
const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
|
|
807
|
+
const chunksize = end - start + 1;
|
|
808
|
+
res.setHeader("Content-Range", `bytes ${start}-${end}/${stat.size}`);
|
|
809
|
+
res.setHeader("Content-Length", chunksize);
|
|
810
|
+
res.status(206);
|
|
811
|
+
import_fs.default.createReadStream(filePath, { start, end }).pipe(res);
|
|
812
|
+
} else {
|
|
813
|
+
res.setHeader("Content-Length", stat.size);
|
|
814
|
+
res.status(200);
|
|
815
|
+
import_fs.default.createReadStream(filePath).pipe(res);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
async _extractFileFromCloudinaryArchive(file, req, res, isInline) {
|
|
819
|
+
try {
|
|
820
|
+
const storageRes = await this.query(`SELECT * FROM ${this.storagesTable} WHERE id = $1`, [file.storage_id]);
|
|
821
|
+
const storageConfig = storageRes.rows[0];
|
|
822
|
+
if (!storageConfig || storageConfig.provider !== "cloudinary") {
|
|
823
|
+
return res.status(401).send("Error loading file from storage");
|
|
824
|
+
}
|
|
825
|
+
const { cloudName, apiKey, apiSecret } = storageConfig.credentials || {};
|
|
826
|
+
if (!cloudName || !apiKey || !apiSecret) {
|
|
827
|
+
return res.status(500).send("Storage credentials missing");
|
|
828
|
+
}
|
|
829
|
+
const cloudinary = await loadCloudinarySdk();
|
|
830
|
+
cloudinary.config({
|
|
831
|
+
cloud_name: cloudName,
|
|
832
|
+
api_key: apiKey,
|
|
833
|
+
api_secret: apiSecret,
|
|
834
|
+
secure: true
|
|
835
|
+
});
|
|
836
|
+
const publicId = file.cloud_public_id || "";
|
|
837
|
+
const fullyQualifiedId = publicId.startsWith("raw/upload/") ? publicId : `raw/upload/${publicId}`;
|
|
838
|
+
const archiveUrl = cloudinary.utils.download_archive_url({
|
|
839
|
+
fully_qualified_public_ids: [fullyQualifiedId]
|
|
840
|
+
});
|
|
841
|
+
const client = archiveUrl.startsWith("https") ? import_https.default : import_http.default;
|
|
842
|
+
const agent = archiveUrl.startsWith("https") ? httpsAgent : httpAgent;
|
|
843
|
+
client.get(archiveUrl, { agent }, (cloudRes) => {
|
|
844
|
+
if (cloudRes.statusCode >= 400) {
|
|
845
|
+
return res.status(cloudRes.statusCode).send("Error loading file from storage");
|
|
846
|
+
}
|
|
847
|
+
const chunks = [];
|
|
848
|
+
cloudRes.on("data", (c) => chunks.push(c));
|
|
849
|
+
cloudRes.on("end", () => {
|
|
850
|
+
const buf = Buffer.concat(chunks);
|
|
851
|
+
if (buf.length > 30 && buf.slice(0, 4).toString("hex") === "504b0304") {
|
|
852
|
+
try {
|
|
853
|
+
const comp = buf.readUInt16LE(8);
|
|
854
|
+
const nameLen = buf.readUInt16LE(26);
|
|
855
|
+
const extraLen = buf.readUInt16LE(28);
|
|
856
|
+
const dataStart = 30 + nameLen + extraLen;
|
|
857
|
+
const cdIdx = buf.indexOf(Buffer.from("504b0102", "hex"));
|
|
858
|
+
const dataEnd = cdIdx !== -1 ? cdIdx - 16 : buf.length;
|
|
859
|
+
const compData = buf.slice(dataStart, dataEnd);
|
|
860
|
+
const decomp = comp === 8 ? import_zlib.default.inflateRawSync(compData) : compData;
|
|
861
|
+
const filename = file.original_name || "file";
|
|
862
|
+
const safeFilename = filename.replace(/["\r\n]/g, "_");
|
|
863
|
+
const encodedFilename = encodeURIComponent(filename);
|
|
864
|
+
const disposition = isInline ? "inline" : "attachment";
|
|
865
|
+
const etag = `"${file.id}-${decomp.length}"`;
|
|
866
|
+
res.setHeader("ETag", etag);
|
|
867
|
+
res.setHeader("Content-Type", file.mime_type || "application/octet-stream");
|
|
868
|
+
res.setHeader(
|
|
869
|
+
"Content-Disposition",
|
|
870
|
+
`${disposition}; filename="${safeFilename}"; filename*=UTF-8''${encodedFilename}`
|
|
871
|
+
);
|
|
872
|
+
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
873
|
+
res.setHeader("Content-Length", decomp.length);
|
|
874
|
+
return res.send(decomp);
|
|
875
|
+
} catch (err) {
|
|
876
|
+
console.error("[cynx/storage] ZIP extraction error:", err);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
res.status(500).send("Error processing file");
|
|
880
|
+
});
|
|
881
|
+
}).on("error", (err) => {
|
|
882
|
+
console.error("[cynx/storage] Cloudinary archive stream error:", err);
|
|
883
|
+
res.status(500).send("Error fetching file");
|
|
884
|
+
});
|
|
885
|
+
} catch (err) {
|
|
886
|
+
console.error("[cynx/storage] extractFileFromCloudinaryArchive error:", err);
|
|
887
|
+
res.status(500).send("Internal server error");
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
handleFileStream(file, req, res, isInline = false, redirectCount = 0) {
|
|
891
|
+
if (redirectCount > 5) {
|
|
892
|
+
return res.status(500).send("Too many redirects");
|
|
893
|
+
}
|
|
894
|
+
const etag = `"${file.id}-${file.file_size || "0"}"`;
|
|
895
|
+
const ifNoneMatch = req?.headers ? req.headers["if-none-match"] : null;
|
|
896
|
+
if (ifNoneMatch && (ifNoneMatch === etag || ifNoneMatch === `W/${etag}`)) {
|
|
897
|
+
res.status(304).end();
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if (file.storage_id) {
|
|
901
|
+
this.query(`SELECT provider FROM ${this.storagesTable} WHERE id = $1`, [file.storage_id]).then((stRes) => {
|
|
902
|
+
const prov = stRes.rows[0]?.provider;
|
|
903
|
+
if (prov === "local") {
|
|
904
|
+
return this._streamFromLocal(file, req, res, isInline);
|
|
905
|
+
}
|
|
906
|
+
if (prov === "s3" || prov === "r2") {
|
|
907
|
+
return this._streamFromS3(file, req, res, isInline);
|
|
908
|
+
}
|
|
909
|
+
this._streamHttp(file, req, res, isInline, redirectCount, prov);
|
|
910
|
+
}).catch(() => {
|
|
911
|
+
this._streamHttp(file, req, res, isInline, redirectCount);
|
|
912
|
+
});
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
this._streamHttp(file, req, res, isInline, redirectCount);
|
|
916
|
+
}
|
|
917
|
+
_streamHttp(file, req, res, isInline, redirectCount = 0, knownProvider = null) {
|
|
918
|
+
let targetUrl = file.cloud_url;
|
|
919
|
+
if (!targetUrl) {
|
|
920
|
+
if (file.storage_id && file.cloud_public_id) {
|
|
921
|
+
return this._streamFromS3(file, req, res, isInline);
|
|
922
|
+
}
|
|
923
|
+
return res.status(404).send("File URL not found");
|
|
924
|
+
}
|
|
925
|
+
if (!/^https?:\/\//i.test(targetUrl)) {
|
|
926
|
+
targetUrl = `https://${targetUrl}`;
|
|
927
|
+
}
|
|
928
|
+
const isHttps = targetUrl.startsWith("https");
|
|
929
|
+
const client = isHttps ? import_https.default : import_http.default;
|
|
930
|
+
const agent = isHttps ? httpsAgent : httpAgent;
|
|
931
|
+
const requestHeaders = {};
|
|
932
|
+
if (req?.headers && req.headers["range"]) {
|
|
933
|
+
requestHeaders["Range"] = req.headers["range"];
|
|
934
|
+
}
|
|
935
|
+
client.get(targetUrl, { agent, headers: requestHeaders }, (cloudRes) => {
|
|
936
|
+
if (cloudRes.statusCode >= 300 && cloudRes.statusCode < 400 && cloudRes.headers.location) {
|
|
937
|
+
const redirectUrl = new URL(cloudRes.headers.location, targetUrl).toString();
|
|
938
|
+
return this.handleFileStream({ ...file, cloud_url: redirectUrl }, req, res, isInline, redirectCount + 1);
|
|
939
|
+
}
|
|
940
|
+
if (cloudRes.statusCode === 401 || cloudRes.statusCode === 403) {
|
|
941
|
+
if (knownProvider === "cloudinary" || !knownProvider && file.storage_id) {
|
|
942
|
+
return this._extractFileFromCloudinaryArchive(file, req, res, isInline);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
if (cloudRes.statusCode >= 400) {
|
|
946
|
+
return res.status(cloudRes.statusCode).send("Error loading file from storage");
|
|
947
|
+
}
|
|
948
|
+
const mimeType = cloudRes.headers["content-type"] || file.mime_type || "application/octet-stream";
|
|
949
|
+
const filename = file.original_name || "file";
|
|
950
|
+
const safeFilename = filename.replace(/["\r\n]/g, "_");
|
|
951
|
+
const encodedFilename = encodeURIComponent(filename);
|
|
952
|
+
const disposition = isInline ? "inline" : "attachment";
|
|
953
|
+
res.setHeader("ETag", `"${file.id}-${file.file_size || "0"}"`);
|
|
954
|
+
res.setHeader("Content-Type", mimeType);
|
|
955
|
+
res.setHeader("Accept-Ranges", "bytes");
|
|
956
|
+
res.setHeader(
|
|
957
|
+
"Content-Disposition",
|
|
958
|
+
`${disposition}; filename="${safeFilename}"; filename*=UTF-8''${encodedFilename}`
|
|
959
|
+
);
|
|
960
|
+
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
961
|
+
if (cloudRes.headers["content-range"]) {
|
|
962
|
+
res.setHeader("Content-Range", cloudRes.headers["content-range"]);
|
|
963
|
+
res.status(206);
|
|
964
|
+
} else {
|
|
965
|
+
res.status(cloudRes.statusCode || 200);
|
|
966
|
+
}
|
|
967
|
+
if (cloudRes.headers["content-length"]) {
|
|
968
|
+
res.setHeader("Content-Length", cloudRes.headers["content-length"]);
|
|
969
|
+
}
|
|
970
|
+
cloudRes.pipe(res);
|
|
971
|
+
}).on("error", (err) => {
|
|
972
|
+
console.error("[cynx/storage] File stream error:", err.message);
|
|
973
|
+
if (file.storage_id && file.cloud_public_id) {
|
|
974
|
+
return this._streamFromS3(file, req, res, isInline);
|
|
975
|
+
}
|
|
976
|
+
if (!res.headersSent) {
|
|
977
|
+
res.status(500).send("Error streaming file");
|
|
978
|
+
}
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
// ─── ENTITY OPERATIONS ─────────────────────────────────────────────────────────
|
|
982
|
+
async uploadEntityFiles({ entityType, entityId = null, files = [], reqUserId = null, folder = null }) {
|
|
983
|
+
if (!files || files.length === 0) return [];
|
|
984
|
+
const targetFolder = folder || entityType || "general";
|
|
985
|
+
if (this.storageServerUrl && this.storageServerApiKey) {
|
|
986
|
+
const formData = new FormData();
|
|
987
|
+
formData.append("entityType", entityType);
|
|
988
|
+
if (entityId) formData.append("entityId", String(entityId));
|
|
989
|
+
if (targetFolder) formData.append("folder", targetFolder);
|
|
990
|
+
for (const f of files) {
|
|
991
|
+
const blob = new Blob([f.buffer], { type: f.mimetype || "application/octet-stream" });
|
|
992
|
+
formData.append("files", blob, f.originalname);
|
|
993
|
+
}
|
|
994
|
+
const response = await fetch(`${this.storageServerUrl}/api/v1/upload`, {
|
|
995
|
+
method: "POST",
|
|
996
|
+
headers: {
|
|
997
|
+
"x-api-key": this.storageServerApiKey
|
|
998
|
+
},
|
|
999
|
+
body: formData
|
|
1000
|
+
});
|
|
1001
|
+
if (!response.ok) {
|
|
1002
|
+
const errJson = await response.json().catch(() => ({}));
|
|
1003
|
+
throw new Error(errJson.error || `Storage server upload failed with status ${response.status}`);
|
|
1004
|
+
}
|
|
1005
|
+
const result = await response.json();
|
|
1006
|
+
const rawFiles = result.files || (result.id ? [result] : []);
|
|
1007
|
+
const isInspection = entityType === "dot_inspection" || entityType === "inspection";
|
|
1008
|
+
const inspectionId = isInspection ? entityId : null;
|
|
1009
|
+
const localRecords = await Promise.all(
|
|
1010
|
+
rawFiles.map(async (rf) => {
|
|
1011
|
+
const fileId = rf.id;
|
|
1012
|
+
const mimeType = rf.mimeType || rf.mime_type || "application/octet-stream";
|
|
1013
|
+
const fileType = rf.fileType || rf.file_type || (0, import_storage.getFileTypeCategory)(mimeType, rf.originalName || rf.original_name);
|
|
1014
|
+
const originalName = rf.originalName || rf.original_name || "file";
|
|
1015
|
+
const fileSize = Number(rf.fileSize || rf.file_size || 0);
|
|
1016
|
+
const cloudUrl = rf.cloudUrl || rf.cloud_url;
|
|
1017
|
+
const cloudPublicId = rf.cloudPublicId || rf.cloud_public_id || fileId;
|
|
1018
|
+
const storageId = rf.storageId || rf.storage_id || null;
|
|
1019
|
+
if (this.query) {
|
|
1020
|
+
const hasInspectionCol = this.filesTable === "storage_files";
|
|
1021
|
+
const insertSql = hasInspectionCol ? `INSERT INTO ${this.filesTable} (
|
|
1022
|
+
id, entity_type, entity_id, inspection_id, storage_id,
|
|
1023
|
+
original_name, file_type, mime_type, file_size, cloud_url, cloud_public_id, created_at, updated_at
|
|
1024
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW())
|
|
1025
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
1026
|
+
entity_type = EXCLUDED.entity_type,
|
|
1027
|
+
entity_id = EXCLUDED.entity_id,
|
|
1028
|
+
inspection_id = EXCLUDED.inspection_id,
|
|
1029
|
+
cloud_url = EXCLUDED.cloud_url,
|
|
1030
|
+
cloud_public_id = EXCLUDED.cloud_public_id,
|
|
1031
|
+
updated_at = NOW()
|
|
1032
|
+
RETURNING *` : `INSERT INTO ${this.filesTable} (
|
|
1033
|
+
id, entity_type, entity_id, storage_id,
|
|
1034
|
+
original_name, file_type, mime_type, file_size, cloud_url, cloud_public_id, created_at, updated_at
|
|
1035
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
|
|
1036
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
1037
|
+
entity_type = EXCLUDED.entity_type,
|
|
1038
|
+
entity_id = EXCLUDED.entity_id,
|
|
1039
|
+
cloud_url = EXCLUDED.cloud_url,
|
|
1040
|
+
cloud_public_id = EXCLUDED.cloud_public_id,
|
|
1041
|
+
updated_at = NOW()
|
|
1042
|
+
RETURNING *`;
|
|
1043
|
+
const params = hasInspectionCol ? [fileId, entityType, entityId, inspectionId, storageId, originalName, fileType, mimeType, fileSize, cloudUrl, cloudPublicId] : [fileId, entityType, entityId, storageId, originalName, fileType, mimeType, fileSize, cloudUrl, cloudPublicId];
|
|
1044
|
+
const r = await this.query(insertSql, params);
|
|
1045
|
+
this.fileMetadataCache.set(fileId, { data: r.rows[0], ts: Date.now() });
|
|
1046
|
+
return this.formatFileRecord(r.rows[0]);
|
|
1047
|
+
}
|
|
1048
|
+
return this.formatFileRecord({
|
|
1049
|
+
id: fileId,
|
|
1050
|
+
entity_type: entityType,
|
|
1051
|
+
entity_id: entityId,
|
|
1052
|
+
storage_id: storageId,
|
|
1053
|
+
original_name: originalName,
|
|
1054
|
+
file_type: fileType,
|
|
1055
|
+
mime_type: mimeType,
|
|
1056
|
+
file_size: fileSize,
|
|
1057
|
+
cloud_url: cloudUrl,
|
|
1058
|
+
cloud_public_id: cloudPublicId
|
|
1059
|
+
});
|
|
1060
|
+
})
|
|
1061
|
+
);
|
|
1062
|
+
return localRecords;
|
|
1063
|
+
}
|
|
1064
|
+
const uploadTasks = files.map(async (file) => {
|
|
1065
|
+
const fileCategory = (0, import_storage.getFileTypeCategory)(file.mimetype, file.originalname);
|
|
1066
|
+
const storage = await this.getAvailableStorage(file.size);
|
|
1067
|
+
const uploaded = await this.uploadToFileStorage(
|
|
1068
|
+
storage,
|
|
1069
|
+
file.buffer,
|
|
1070
|
+
file.originalname,
|
|
1071
|
+
file.mimetype,
|
|
1072
|
+
targetFolder
|
|
1073
|
+
);
|
|
1074
|
+
const fileId = (0, import_uid.genUid)();
|
|
1075
|
+
const isInspection = entityType === "dot_inspection" || entityType === "inspection";
|
|
1076
|
+
const inspectionId = isInspection ? entityId : null;
|
|
1077
|
+
const hasInspectionCol = this.filesTable === "storage_files";
|
|
1078
|
+
const insertSql = hasInspectionCol ? `INSERT INTO ${this.filesTable} (
|
|
1079
|
+
id, entity_type, entity_id, inspection_id, storage_id,
|
|
1080
|
+
original_name, file_type, mime_type, file_size, cloud_url, cloud_public_id
|
|
1081
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
1082
|
+
RETURNING *` : `INSERT INTO ${this.filesTable} (
|
|
1083
|
+
id, entity_type, entity_id, storage_id,
|
|
1084
|
+
original_name, file_type, mime_type, file_size, cloud_url, cloud_public_id
|
|
1085
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
1086
|
+
RETURNING *`;
|
|
1087
|
+
const params = hasInspectionCol ? [fileId, entityType, entityId, inspectionId, storage.id, file.originalname, fileCategory, file.mimetype, file.size, uploaded.cloudUrl, uploaded.cloudPublicId] : [fileId, entityType, entityId, storage.id, file.originalname, fileCategory, file.mimetype, file.size, uploaded.cloudUrl, uploaded.cloudPublicId];
|
|
1088
|
+
const r = await this.query(insertSql, params);
|
|
1089
|
+
await this.updateStorageUsage(storage.id, file.size);
|
|
1090
|
+
const formatted = this.formatFileRecord(r.rows[0]);
|
|
1091
|
+
this.fileMetadataCache.set(fileId, { data: r.rows[0], ts: Date.now() });
|
|
1092
|
+
return formatted;
|
|
1093
|
+
});
|
|
1094
|
+
return await Promise.all(uploadTasks);
|
|
1095
|
+
}
|
|
1096
|
+
async deleteFileById(fileId) {
|
|
1097
|
+
const file = await this.getCachedFileRecord(fileId);
|
|
1098
|
+
if (!file) return false;
|
|
1099
|
+
if (this.storageServerUrl && this.storageServerApiKey) {
|
|
1100
|
+
try {
|
|
1101
|
+
await fetch(`${this.storageServerUrl}/api/v1/files/${encodeURIComponent(fileId)}`, {
|
|
1102
|
+
method: "DELETE",
|
|
1103
|
+
headers: { "x-api-key": this.storageServerApiKey }
|
|
1104
|
+
});
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
console.warn("[cynx/storage] StorageServer Delete Warning:", err.message);
|
|
1107
|
+
}
|
|
1108
|
+
} else if (file.storage_id && this.query) {
|
|
1109
|
+
const storageRes = await this.query(`SELECT * FROM ${this.storagesTable} WHERE id = $1`, [file.storage_id]);
|
|
1110
|
+
const storage = storageRes.rows[0];
|
|
1111
|
+
if (storage) {
|
|
1112
|
+
await this.deleteFromFileStorage(storage, file.cloud_public_id, file.cloud_url, file.mime_type);
|
|
1113
|
+
await this.updateStorageUsage(storage.id, -Number(file.file_size || 0));
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
if (this.query) {
|
|
1117
|
+
await this.query(`DELETE FROM ${this.filesTable} WHERE id = $1`, [fileId]);
|
|
1118
|
+
}
|
|
1119
|
+
this.invalidateFileCache(fileId);
|
|
1120
|
+
return true;
|
|
1121
|
+
}
|
|
1122
|
+
async deleteFilesByEntity(entityType, entityId) {
|
|
1123
|
+
const types = entityType === "errors" || entityType === "employee_error" || entityType === "emp_errors" ? ["errors", "employee_error", "emp_errors"] : [entityType];
|
|
1124
|
+
if (this.storageServerUrl && this.storageServerApiKey) {
|
|
1125
|
+
try {
|
|
1126
|
+
await fetch(`${this.storageServerUrl}/api/v1/entity/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`, {
|
|
1127
|
+
method: "DELETE",
|
|
1128
|
+
headers: { "x-api-key": this.storageServerApiKey }
|
|
1129
|
+
});
|
|
1130
|
+
} catch (err) {
|
|
1131
|
+
console.warn("[cynx/storage] StorageServer Delete Entity Warning:", err.message);
|
|
1132
|
+
}
|
|
1133
|
+
} else if (this.query) {
|
|
1134
|
+
const hasInspectionCol = this.filesTable === "storage_files";
|
|
1135
|
+
const selectSql = hasInspectionCol ? `SELECT * FROM ${this.filesTable} WHERE entity_type = ANY($1::text[]) AND (entity_id = $2 OR inspection_id = $2)` : `SELECT * FROM ${this.filesTable} WHERE entity_type = ANY($1::text[]) AND entity_id = $2`;
|
|
1136
|
+
const filesRes = await this.query(selectSql, [types, String(entityId)]);
|
|
1137
|
+
for (const file of filesRes.rows) {
|
|
1138
|
+
this.invalidateFileCache(file.id);
|
|
1139
|
+
if (file.storage_id) {
|
|
1140
|
+
const storageRes = await this.query(`SELECT * FROM ${this.storagesTable} WHERE id = $1`, [file.storage_id]);
|
|
1141
|
+
const storage = storageRes.rows[0];
|
|
1142
|
+
if (storage) {
|
|
1143
|
+
await this.deleteFromFileStorage(storage, file.cloud_public_id, file.cloud_url, file.mime_type);
|
|
1144
|
+
await this.updateStorageUsage(storage.id, -Number(file.file_size || 0));
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
if (this.query) {
|
|
1150
|
+
const hasInspectionCol = this.filesTable === "storage_files";
|
|
1151
|
+
const deleteSql = hasInspectionCol ? `DELETE FROM ${this.filesTable} WHERE entity_type = ANY($1::text[]) AND (entity_id = $2 OR inspection_id = $2)` : `DELETE FROM ${this.filesTable} WHERE entity_type = ANY($1::text[]) AND entity_id = $2`;
|
|
1152
|
+
await this.query(deleteSql, [types, String(entityId)]);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
async getEntityFiles(entityType, entityId) {
|
|
1156
|
+
if (!this.query) return [];
|
|
1157
|
+
const types = entityType === "errors" || entityType === "employee_error" || entityType === "emp_errors" ? ["errors", "employee_error", "emp_errors"] : [entityType];
|
|
1158
|
+
const hasInspectionCol = this.filesTable === "storage_files";
|
|
1159
|
+
const selectSql = hasInspectionCol ? `SELECT * FROM ${this.filesTable} WHERE entity_type = ANY($1::text[]) AND (entity_id = $2 OR inspection_id = $2) ORDER BY created_at ASC` : `SELECT * FROM ${this.filesTable} WHERE entity_type = ANY($1::text[]) AND entity_id = $2 ORDER BY created_at ASC`;
|
|
1160
|
+
const r = await this.query(selectSql, [types, String(entityId)]);
|
|
1161
|
+
return r.rows.map((row) => this.formatFileRecord(row));
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
function createStorageManager(options = {}) {
|
|
1165
|
+
return new StorageManager(options);
|
|
1166
|
+
}
|
|
1167
|
+
var storage_manager_default = {
|
|
1168
|
+
StorageManager,
|
|
1169
|
+
createStorageManager,
|
|
1170
|
+
STORAGE_PROVIDERS
|
|
1171
|
+
};
|
|
1172
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1173
|
+
0 && (module.exports = {
|
|
1174
|
+
STORAGE_PROVIDERS,
|
|
1175
|
+
StorageManager,
|
|
1176
|
+
createStorageManager
|
|
1177
|
+
});
|