@nixxie-cms/storage 1.0.1
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/LICENSE +23 -0
- package/README.md +44 -0
- package/dist/declarations/src/AzureStorage.d.ts +18 -0
- package/dist/declarations/src/AzureStorage.d.ts.map +1 -0
- package/dist/declarations/src/GcsStorage.d.ts +18 -0
- package/dist/declarations/src/GcsStorage.d.ts.map +1 -0
- package/dist/declarations/src/LocalStorage.d.ts +20 -0
- package/dist/declarations/src/LocalStorage.d.ts.map +1 -0
- package/dist/declarations/src/S3Storage.d.ts +20 -0
- package/dist/declarations/src/S3Storage.d.ts.map +1 -0
- package/dist/declarations/src/index.d.ts +10 -0
- package/dist/declarations/src/index.d.ts.map +1 -0
- package/dist/declarations/src/types.d.ts +54 -0
- package/dist/declarations/src/types.d.ts.map +1 -0
- package/dist/nixxie-cms-storage.cjs.d.ts +2 -0
- package/dist/nixxie-cms-storage.cjs.js +399 -0
- package/dist/nixxie-cms-storage.esm.js +391 -0
- package/package.json +39 -0
- package/src/AzureStorage.ts +104 -0
- package/src/GcsStorage.ts +92 -0
- package/src/LocalStorage.ts +99 -0
- package/src/S3Storage.ts +138 -0
- package/src/index.ts +39 -0
- package/src/types.ts +68 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, writeFile, readFile, rm, stat, readdir } from 'node:fs/promises';
|
|
3
|
+
import { resolve, sep, dirname, join, relative, posix } from 'node:path';
|
|
4
|
+
|
|
5
|
+
function loadAzure() {
|
|
6
|
+
try {
|
|
7
|
+
return require('@azure/storage-blob');
|
|
8
|
+
} catch {
|
|
9
|
+
throw new Error('@azure/storage-blob is required for the Azure driver. Run: npm install @azure/storage-blob');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Azure Blob Storage backend. */
|
|
14
|
+
class AzureStorage {
|
|
15
|
+
constructor(config) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.prefix = config.prefix ? config.prefix.replace(/\/$/, '') + '/' : '';
|
|
18
|
+
const {
|
|
19
|
+
BlobServiceClient
|
|
20
|
+
} = loadAzure();
|
|
21
|
+
const service = BlobServiceClient.fromConnectionString(config.connectionString);
|
|
22
|
+
this.container = service.getContainerClient(config.container);
|
|
23
|
+
}
|
|
24
|
+
blob(key) {
|
|
25
|
+
return this.container.getBlockBlobClient(`${this.prefix}${key}`);
|
|
26
|
+
}
|
|
27
|
+
async put(key, data, options) {
|
|
28
|
+
const body = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data);
|
|
29
|
+
await this.blob(key).uploadData(body, {
|
|
30
|
+
blobHTTPHeaders: options !== null && options !== void 0 && options.contentType ? {
|
|
31
|
+
blobContentType: options.contentType
|
|
32
|
+
} : undefined,
|
|
33
|
+
metadata: options === null || options === void 0 ? void 0 : options.metadata
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
key,
|
|
37
|
+
url: this.url(key),
|
|
38
|
+
size: body.byteLength,
|
|
39
|
+
contentType: options === null || options === void 0 ? void 0 : options.contentType
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async get(key) {
|
|
43
|
+
try {
|
|
44
|
+
return await this.blob(key).downloadToBuffer();
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if ((err === null || err === void 0 ? void 0 : err.statusCode) === 404) return undefined;
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async delete(key) {
|
|
51
|
+
await this.blob(key).deleteIfExists();
|
|
52
|
+
}
|
|
53
|
+
async has(key) {
|
|
54
|
+
return Boolean(await this.blob(key).exists());
|
|
55
|
+
}
|
|
56
|
+
url(key) {
|
|
57
|
+
if (this.config.publicBaseUrl) return `${this.config.publicBaseUrl.replace(/\/$/, '')}/${this.prefix}${key}`;
|
|
58
|
+
return this.blob(key).url;
|
|
59
|
+
}
|
|
60
|
+
async signedUrl(key, options) {
|
|
61
|
+
var _credential, _options$expiresIn;
|
|
62
|
+
const {
|
|
63
|
+
BlobSASPermissions,
|
|
64
|
+
generateBlobSASQueryParameters
|
|
65
|
+
} = loadAzure();
|
|
66
|
+
const blob = this.blob(key);
|
|
67
|
+
const credential = (_credential = this.container.credential) !== null && _credential !== void 0 ? _credential : blob.credential;
|
|
68
|
+
const expiresOn = new Date(Date.now() + ((_options$expiresIn = options === null || options === void 0 ? void 0 : options.expiresIn) !== null && _options$expiresIn !== void 0 ? _options$expiresIn : 900) * 1000);
|
|
69
|
+
const permissions = BlobSASPermissions.parse((options === null || options === void 0 ? void 0 : options.operation) === 'put' ? 'cw' : 'r');
|
|
70
|
+
try {
|
|
71
|
+
const sas = generateBlobSASQueryParameters({
|
|
72
|
+
containerName: this.config.container,
|
|
73
|
+
blobName: `${this.prefix}${key}`,
|
|
74
|
+
permissions,
|
|
75
|
+
expiresOn
|
|
76
|
+
}, credential).toString();
|
|
77
|
+
return `${blob.url}?${sas}`;
|
|
78
|
+
} catch {
|
|
79
|
+
// Falls back to the plain URL when SAS generation is unavailable (e.g. token credential).
|
|
80
|
+
return blob.url;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async list(prefix = '') {
|
|
84
|
+
const out = [];
|
|
85
|
+
for await (const item of this.container.listBlobsFlat({
|
|
86
|
+
prefix: `${this.prefix}${prefix}`
|
|
87
|
+
})) {
|
|
88
|
+
const name = item.name;
|
|
89
|
+
out.push(this.prefix && name.startsWith(this.prefix) ? name.slice(this.prefix.length) : name);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function loadGcs() {
|
|
96
|
+
try {
|
|
97
|
+
return require('@google-cloud/storage');
|
|
98
|
+
} catch {
|
|
99
|
+
throw new Error('@google-cloud/storage is required for the GCS driver. Run: npm install @google-cloud/storage');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Google Cloud Storage backend. */
|
|
104
|
+
class GcsStorage {
|
|
105
|
+
constructor(config) {
|
|
106
|
+
this.config = config;
|
|
107
|
+
this.prefix = config.prefix ? config.prefix.replace(/\/$/, '') + '/' : '';
|
|
108
|
+
const {
|
|
109
|
+
Storage
|
|
110
|
+
} = loadGcs();
|
|
111
|
+
const client = new Storage({
|
|
112
|
+
projectId: config.projectId,
|
|
113
|
+
keyFilename: config.keyFilename
|
|
114
|
+
});
|
|
115
|
+
this.bucket = client.bucket(config.bucket);
|
|
116
|
+
}
|
|
117
|
+
file(key) {
|
|
118
|
+
return this.bucket.file(`${this.prefix}${key}`);
|
|
119
|
+
}
|
|
120
|
+
async put(key, data, options) {
|
|
121
|
+
const body = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data);
|
|
122
|
+
const file = this.file(key);
|
|
123
|
+
await file.save(body, {
|
|
124
|
+
contentType: options === null || options === void 0 ? void 0 : options.contentType,
|
|
125
|
+
metadata: options !== null && options !== void 0 && options.metadata ? {
|
|
126
|
+
metadata: options.metadata
|
|
127
|
+
} : undefined
|
|
128
|
+
});
|
|
129
|
+
if (options !== null && options !== void 0 && options.public) await file.makePublic();
|
|
130
|
+
return {
|
|
131
|
+
key,
|
|
132
|
+
url: this.url(key),
|
|
133
|
+
size: body.byteLength,
|
|
134
|
+
contentType: options === null || options === void 0 ? void 0 : options.contentType
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async get(key) {
|
|
138
|
+
try {
|
|
139
|
+
const [contents] = await this.file(key).download();
|
|
140
|
+
return contents;
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if ((err === null || err === void 0 ? void 0 : err.code) === 404) return undefined;
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async delete(key) {
|
|
147
|
+
await this.file(key).delete({
|
|
148
|
+
ignoreNotFound: true
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
async has(key) {
|
|
152
|
+
const [exists] = await this.file(key).exists();
|
|
153
|
+
return Boolean(exists);
|
|
154
|
+
}
|
|
155
|
+
url(key) {
|
|
156
|
+
if (this.config.publicBaseUrl) return `${this.config.publicBaseUrl.replace(/\/$/, '')}/${this.prefix}${key}`;
|
|
157
|
+
return `https://storage.googleapis.com/${this.config.bucket}/${this.prefix}${key}`;
|
|
158
|
+
}
|
|
159
|
+
async signedUrl(key, options) {
|
|
160
|
+
var _options$expiresIn;
|
|
161
|
+
const [url] = await this.file(key).getSignedUrl({
|
|
162
|
+
action: (options === null || options === void 0 ? void 0 : options.operation) === 'put' ? 'write' : 'read',
|
|
163
|
+
expires: Date.now() + ((_options$expiresIn = options === null || options === void 0 ? void 0 : options.expiresIn) !== null && _options$expiresIn !== void 0 ? _options$expiresIn : 900) * 1000,
|
|
164
|
+
contentType: (options === null || options === void 0 ? void 0 : options.operation) === 'put' ? options === null || options === void 0 ? void 0 : options.contentType : undefined
|
|
165
|
+
});
|
|
166
|
+
return url;
|
|
167
|
+
}
|
|
168
|
+
async list(prefix = '') {
|
|
169
|
+
const [files] = await this.bucket.getFiles({
|
|
170
|
+
prefix: `${this.prefix}${prefix}`
|
|
171
|
+
});
|
|
172
|
+
return files.map(f => f.name).map(k => this.prefix && k.startsWith(this.prefix) ? k.slice(this.prefix.length) : k);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Filesystem-backed storage. Good for development and single-node deployments.
|
|
178
|
+
* `signedUrl()` falls back to the plain public URL since the local disk cannot sign.
|
|
179
|
+
*/
|
|
180
|
+
class LocalStorage {
|
|
181
|
+
constructor(config) {
|
|
182
|
+
var _config$baseDir, _config$baseUrl;
|
|
183
|
+
this.baseDir = (_config$baseDir = config.baseDir) !== null && _config$baseDir !== void 0 ? _config$baseDir : '.nixxie-storage';
|
|
184
|
+
this.baseUrl = ((_config$baseUrl = config.baseUrl) !== null && _config$baseUrl !== void 0 ? _config$baseUrl : '/files').replace(/\/$/, '');
|
|
185
|
+
}
|
|
186
|
+
path(key) {
|
|
187
|
+
// Confine resolved paths to baseDir so keys like "../../etc/passwd" (or absolute paths)
|
|
188
|
+
// cannot escape the storage root.
|
|
189
|
+
const root = resolve(this.baseDir);
|
|
190
|
+
const full = resolve(root, key);
|
|
191
|
+
if (full !== root && !full.startsWith(root + sep)) {
|
|
192
|
+
throw new Error(`Invalid storage key (path traversal detected): ${key}`);
|
|
193
|
+
}
|
|
194
|
+
return full;
|
|
195
|
+
}
|
|
196
|
+
async put(key, data, options) {
|
|
197
|
+
const filePath = this.path(key);
|
|
198
|
+
await mkdir(dirname(filePath), {
|
|
199
|
+
recursive: true
|
|
200
|
+
});
|
|
201
|
+
const buffer = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data);
|
|
202
|
+
await writeFile(filePath, buffer);
|
|
203
|
+
return {
|
|
204
|
+
key,
|
|
205
|
+
url: this.url(key),
|
|
206
|
+
size: buffer.byteLength,
|
|
207
|
+
contentType: options === null || options === void 0 ? void 0 : options.contentType
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
async get(key) {
|
|
211
|
+
const filePath = this.path(key);
|
|
212
|
+
if (!existsSync(filePath)) return undefined;
|
|
213
|
+
return readFile(filePath);
|
|
214
|
+
}
|
|
215
|
+
async delete(key) {
|
|
216
|
+
await rm(this.path(key), {
|
|
217
|
+
force: true
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
async has(key) {
|
|
221
|
+
try {
|
|
222
|
+
await stat(this.path(key));
|
|
223
|
+
return true;
|
|
224
|
+
} catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
url(key) {
|
|
229
|
+
return `${this.baseUrl}/${key.split(sep).join('/')}`;
|
|
230
|
+
}
|
|
231
|
+
async signedUrl(key, _options) {
|
|
232
|
+
return this.url(key);
|
|
233
|
+
}
|
|
234
|
+
async list(prefix = '') {
|
|
235
|
+
const root = this.baseDir;
|
|
236
|
+
const out = [];
|
|
237
|
+
const walk = async dir => {
|
|
238
|
+
let entries;
|
|
239
|
+
try {
|
|
240
|
+
entries = await readdir(dir, {
|
|
241
|
+
withFileTypes: true
|
|
242
|
+
});
|
|
243
|
+
} catch {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
for (const entry of entries) {
|
|
247
|
+
const full = join(dir, entry.name);
|
|
248
|
+
if (entry.isDirectory()) await walk(full);else out.push(relative(root, full).split(sep).join(posix.sep));
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
await walk(root);
|
|
252
|
+
return out.filter(k => k.startsWith(prefix));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function loadS3() {
|
|
257
|
+
try {
|
|
258
|
+
return {
|
|
259
|
+
s3: require('@aws-sdk/client-s3'),
|
|
260
|
+
presigner: require('@aws-sdk/s3-request-presigner')
|
|
261
|
+
};
|
|
262
|
+
} catch {
|
|
263
|
+
throw new Error('@aws-sdk/client-s3 and @aws-sdk/s3-request-presigner are required for the S3 driver. Run: npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** AWS S3 (and S3-compatible: R2, MinIO, Spaces) storage backend. */
|
|
268
|
+
class S3Storage {
|
|
269
|
+
constructor(config) {
|
|
270
|
+
this.config = config;
|
|
271
|
+
this.prefix = config.prefix ? config.prefix.replace(/\/$/, '') + '/' : '';
|
|
272
|
+
const {
|
|
273
|
+
s3,
|
|
274
|
+
presigner
|
|
275
|
+
} = loadS3();
|
|
276
|
+
this.s3 = s3;
|
|
277
|
+
this.presigner = presigner;
|
|
278
|
+
this.client = new s3.S3Client({
|
|
279
|
+
region: config.region,
|
|
280
|
+
endpoint: config.endpoint,
|
|
281
|
+
forcePathStyle: config.forcePathStyle,
|
|
282
|
+
credentials: config.accessKeyId && config.secretAccessKey ? {
|
|
283
|
+
accessKeyId: config.accessKeyId,
|
|
284
|
+
secretAccessKey: config.secretAccessKey
|
|
285
|
+
} : undefined
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
k(key) {
|
|
289
|
+
return `${this.prefix}${key}`;
|
|
290
|
+
}
|
|
291
|
+
async put(key, data, options) {
|
|
292
|
+
const body = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data);
|
|
293
|
+
await this.client.send(new this.s3.PutObjectCommand({
|
|
294
|
+
Bucket: this.config.bucket,
|
|
295
|
+
Key: this.k(key),
|
|
296
|
+
Body: body,
|
|
297
|
+
ContentType: options === null || options === void 0 ? void 0 : options.contentType,
|
|
298
|
+
ACL: options !== null && options !== void 0 && options.public ? 'public-read' : undefined,
|
|
299
|
+
Metadata: options === null || options === void 0 ? void 0 : options.metadata
|
|
300
|
+
}));
|
|
301
|
+
return {
|
|
302
|
+
key,
|
|
303
|
+
url: this.url(key),
|
|
304
|
+
size: body.byteLength,
|
|
305
|
+
contentType: options === null || options === void 0 ? void 0 : options.contentType
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
async get(key) {
|
|
309
|
+
try {
|
|
310
|
+
var _res$Body;
|
|
311
|
+
const res = await this.client.send(new this.s3.GetObjectCommand({
|
|
312
|
+
Bucket: this.config.bucket,
|
|
313
|
+
Key: this.k(key)
|
|
314
|
+
}));
|
|
315
|
+
const bytes = await ((_res$Body = res.Body) === null || _res$Body === void 0 ? void 0 : _res$Body.transformToByteArray());
|
|
316
|
+
return bytes ? Buffer.from(bytes) : undefined;
|
|
317
|
+
} catch (err) {
|
|
318
|
+
var _err$$metadata;
|
|
319
|
+
if ((err === null || err === void 0 ? void 0 : err.name) === 'NoSuchKey' || (err === null || err === void 0 || (_err$$metadata = err.$metadata) === null || _err$$metadata === void 0 ? void 0 : _err$$metadata.httpStatusCode) === 404) return undefined;
|
|
320
|
+
throw err;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async delete(key) {
|
|
324
|
+
await this.client.send(new this.s3.DeleteObjectCommand({
|
|
325
|
+
Bucket: this.config.bucket,
|
|
326
|
+
Key: this.k(key)
|
|
327
|
+
}));
|
|
328
|
+
}
|
|
329
|
+
async has(key) {
|
|
330
|
+
try {
|
|
331
|
+
await this.client.send(new this.s3.HeadObjectCommand({
|
|
332
|
+
Bucket: this.config.bucket,
|
|
333
|
+
Key: this.k(key)
|
|
334
|
+
}));
|
|
335
|
+
return true;
|
|
336
|
+
} catch {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
url(key) {
|
|
341
|
+
var _this$config$region;
|
|
342
|
+
if (this.config.publicBaseUrl) return `${this.config.publicBaseUrl.replace(/\/$/, '')}/${this.k(key)}`;
|
|
343
|
+
const host = this.config.endpoint ? `${this.config.endpoint.replace(/\/$/, '')}/${this.config.bucket}` : `https://${this.config.bucket}.s3.${(_this$config$region = this.config.region) !== null && _this$config$region !== void 0 ? _this$config$region : 'us-east-1'}.amazonaws.com`;
|
|
344
|
+
return `${host}/${this.k(key)}`;
|
|
345
|
+
}
|
|
346
|
+
async signedUrl(key, options) {
|
|
347
|
+
var _options$expiresIn;
|
|
348
|
+
const command = (options === null || options === void 0 ? void 0 : options.operation) === 'put' ? new this.s3.PutObjectCommand({
|
|
349
|
+
Bucket: this.config.bucket,
|
|
350
|
+
Key: this.k(key),
|
|
351
|
+
ContentType: options.contentType
|
|
352
|
+
}) : new this.s3.GetObjectCommand({
|
|
353
|
+
Bucket: this.config.bucket,
|
|
354
|
+
Key: this.k(key)
|
|
355
|
+
});
|
|
356
|
+
return this.presigner.getSignedUrl(this.client, command, {
|
|
357
|
+
expiresIn: (_options$expiresIn = options === null || options === void 0 ? void 0 : options.expiresIn) !== null && _options$expiresIn !== void 0 ? _options$expiresIn : 900
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
async list(prefix = '') {
|
|
361
|
+
var _res$Contents;
|
|
362
|
+
const res = await this.client.send(new this.s3.ListObjectsV2Command({
|
|
363
|
+
Bucket: this.config.bucket,
|
|
364
|
+
Prefix: this.k(prefix)
|
|
365
|
+
}));
|
|
366
|
+
return ((_res$Contents = res.Contents) !== null && _res$Contents !== void 0 ? _res$Contents : []).map(o => {
|
|
367
|
+
var _o$Key;
|
|
368
|
+
return (_o$Key = o.Key) !== null && _o$Key !== void 0 ? _o$Key : '';
|
|
369
|
+
}).map(k => this.prefix && k.startsWith(this.prefix) ? k.slice(this.prefix.length) : k).filter(Boolean);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function createStorage(config) {
|
|
374
|
+
switch (config.driver) {
|
|
375
|
+
case 'local':
|
|
376
|
+
return new LocalStorage(config);
|
|
377
|
+
case 's3':
|
|
378
|
+
return new S3Storage(config);
|
|
379
|
+
case 'gcs':
|
|
380
|
+
return new GcsStorage(config);
|
|
381
|
+
case 'azure':
|
|
382
|
+
return new AzureStorage(config);
|
|
383
|
+
default:
|
|
384
|
+
{
|
|
385
|
+
const exhaustive = config;
|
|
386
|
+
throw new Error(`Unknown storage driver: ${exhaustive.driver}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export { AzureStorage, GcsStorage, LocalStorage, S3Storage, createStorage };
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nixxie-cms/storage",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"main": "dist/nixxie-cms-storage.cjs.js",
|
|
6
|
+
"module": "dist/nixxie-cms-storage.esm.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/nixxie-cms-storage.cjs.js",
|
|
10
|
+
"module": "./dist/nixxie-cms-storage.esm.js",
|
|
11
|
+
"default": "./dist/nixxie-cms-storage.cjs.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@babel/runtime": "^7.24.7"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@aws-sdk/client-s3": "^3.1037.0",
|
|
20
|
+
"@aws-sdk/s3-request-presigner": "^3.1037.0",
|
|
21
|
+
"@nixxie-cms/core": "^1.0.1"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@nixxie-cms/core": "^1.0.1"
|
|
25
|
+
},
|
|
26
|
+
"optionalDependencies": {
|
|
27
|
+
"@aws-sdk/client-s3": "^3.1037.0",
|
|
28
|
+
"@aws-sdk/s3-request-presigner": "^3.1037.0"
|
|
29
|
+
},
|
|
30
|
+
"preconstruct": {
|
|
31
|
+
"entrypoints": [
|
|
32
|
+
"index.ts"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/nixxiecms/nixxie/tree/main/packages/storage"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NixxiePutOptions,
|
|
3
|
+
NixxieSignedUrlOptions,
|
|
4
|
+
NixxieStorageService,
|
|
5
|
+
NixxieStoredFile,
|
|
6
|
+
} from '@nixxie-cms/core'
|
|
7
|
+
import type { AzureStorageConfig } from './types'
|
|
8
|
+
|
|
9
|
+
function loadAzure(): any {
|
|
10
|
+
try {
|
|
11
|
+
return require('@azure/storage-blob')
|
|
12
|
+
} catch {
|
|
13
|
+
throw new Error(
|
|
14
|
+
'@azure/storage-blob is required for the Azure driver. Run: npm install @azure/storage-blob'
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Azure Blob Storage backend. */
|
|
20
|
+
export class AzureStorage implements NixxieStorageService {
|
|
21
|
+
private config: AzureStorageConfig
|
|
22
|
+
private prefix: string
|
|
23
|
+
private container: any
|
|
24
|
+
|
|
25
|
+
constructor(config: AzureStorageConfig) {
|
|
26
|
+
this.config = config
|
|
27
|
+
this.prefix = config.prefix ? config.prefix.replace(/\/$/, '') + '/' : ''
|
|
28
|
+
const { BlobServiceClient } = loadAzure()
|
|
29
|
+
const service = BlobServiceClient.fromConnectionString(config.connectionString)
|
|
30
|
+
this.container = service.getContainerClient(config.container)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private blob(key: string): any {
|
|
34
|
+
return this.container.getBlockBlobClient(`${this.prefix}${key}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async put(
|
|
38
|
+
key: string,
|
|
39
|
+
data: Buffer | Uint8Array | string,
|
|
40
|
+
options?: NixxiePutOptions
|
|
41
|
+
): Promise<NixxieStoredFile> {
|
|
42
|
+
const body = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data)
|
|
43
|
+
await this.blob(key).uploadData(body, {
|
|
44
|
+
blobHTTPHeaders: options?.contentType ? { blobContentType: options.contentType } : undefined,
|
|
45
|
+
metadata: options?.metadata,
|
|
46
|
+
})
|
|
47
|
+
return { key, url: this.url(key), size: body.byteLength, contentType: options?.contentType }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async get(key: string): Promise<Buffer | undefined> {
|
|
51
|
+
try {
|
|
52
|
+
return (await this.blob(key).downloadToBuffer()) as Buffer
|
|
53
|
+
} catch (err: any) {
|
|
54
|
+
if (err?.statusCode === 404) return undefined
|
|
55
|
+
throw err
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async delete(key: string): Promise<void> {
|
|
60
|
+
await this.blob(key).deleteIfExists()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async has(key: string): Promise<boolean> {
|
|
64
|
+
return Boolean(await this.blob(key).exists())
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
url(key: string): string {
|
|
68
|
+
if (this.config.publicBaseUrl)
|
|
69
|
+
return `${this.config.publicBaseUrl.replace(/\/$/, '')}/${this.prefix}${key}`
|
|
70
|
+
return this.blob(key).url as string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async signedUrl(key: string, options?: NixxieSignedUrlOptions): Promise<string> {
|
|
74
|
+
const { BlobSASPermissions, generateBlobSASQueryParameters } = loadAzure()
|
|
75
|
+
const blob = this.blob(key)
|
|
76
|
+
const credential = (this.container as any).credential ?? blob.credential
|
|
77
|
+
const expiresOn = new Date(Date.now() + (options?.expiresIn ?? 900) * 1000)
|
|
78
|
+
const permissions = BlobSASPermissions.parse(options?.operation === 'put' ? 'cw' : 'r')
|
|
79
|
+
try {
|
|
80
|
+
const sas = generateBlobSASQueryParameters(
|
|
81
|
+
{
|
|
82
|
+
containerName: this.config.container,
|
|
83
|
+
blobName: `${this.prefix}${key}`,
|
|
84
|
+
permissions,
|
|
85
|
+
expiresOn,
|
|
86
|
+
},
|
|
87
|
+
credential
|
|
88
|
+
).toString()
|
|
89
|
+
return `${blob.url}?${sas}`
|
|
90
|
+
} catch {
|
|
91
|
+
// Falls back to the plain URL when SAS generation is unavailable (e.g. token credential).
|
|
92
|
+
return blob.url as string
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async list(prefix = ''): Promise<string[]> {
|
|
97
|
+
const out: string[] = []
|
|
98
|
+
for await (const item of this.container.listBlobsFlat({ prefix: `${this.prefix}${prefix}` })) {
|
|
99
|
+
const name = item.name as string
|
|
100
|
+
out.push(this.prefix && name.startsWith(this.prefix) ? name.slice(this.prefix.length) : name)
|
|
101
|
+
}
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NixxiePutOptions,
|
|
3
|
+
NixxieSignedUrlOptions,
|
|
4
|
+
NixxieStorageService,
|
|
5
|
+
NixxieStoredFile,
|
|
6
|
+
} from '@nixxie-cms/core'
|
|
7
|
+
import type { GcsStorageConfig } from './types'
|
|
8
|
+
|
|
9
|
+
function loadGcs(): any {
|
|
10
|
+
try {
|
|
11
|
+
return require('@google-cloud/storage')
|
|
12
|
+
} catch {
|
|
13
|
+
throw new Error(
|
|
14
|
+
'@google-cloud/storage is required for the GCS driver. Run: npm install @google-cloud/storage'
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Google Cloud Storage backend. */
|
|
20
|
+
export class GcsStorage implements NixxieStorageService {
|
|
21
|
+
private config: GcsStorageConfig
|
|
22
|
+
private prefix: string
|
|
23
|
+
private bucket: any
|
|
24
|
+
|
|
25
|
+
constructor(config: GcsStorageConfig) {
|
|
26
|
+
this.config = config
|
|
27
|
+
this.prefix = config.prefix ? config.prefix.replace(/\/$/, '') + '/' : ''
|
|
28
|
+
const { Storage } = loadGcs()
|
|
29
|
+
const client = new Storage({ projectId: config.projectId, keyFilename: config.keyFilename })
|
|
30
|
+
this.bucket = client.bucket(config.bucket)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private file(key: string): any {
|
|
34
|
+
return this.bucket.file(`${this.prefix}${key}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async put(
|
|
38
|
+
key: string,
|
|
39
|
+
data: Buffer | Uint8Array | string,
|
|
40
|
+
options?: NixxiePutOptions
|
|
41
|
+
): Promise<NixxieStoredFile> {
|
|
42
|
+
const body = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data)
|
|
43
|
+
const file = this.file(key)
|
|
44
|
+
await file.save(body, {
|
|
45
|
+
contentType: options?.contentType,
|
|
46
|
+
metadata: options?.metadata ? { metadata: options.metadata } : undefined,
|
|
47
|
+
})
|
|
48
|
+
if (options?.public) await file.makePublic()
|
|
49
|
+
return { key, url: this.url(key), size: body.byteLength, contentType: options?.contentType }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async get(key: string): Promise<Buffer | undefined> {
|
|
53
|
+
try {
|
|
54
|
+
const [contents] = await this.file(key).download()
|
|
55
|
+
return contents as Buffer
|
|
56
|
+
} catch (err: any) {
|
|
57
|
+
if (err?.code === 404) return undefined
|
|
58
|
+
throw err
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async delete(key: string): Promise<void> {
|
|
63
|
+
await this.file(key).delete({ ignoreNotFound: true })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async has(key: string): Promise<boolean> {
|
|
67
|
+
const [exists] = await this.file(key).exists()
|
|
68
|
+
return Boolean(exists)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
url(key: string): string {
|
|
72
|
+
if (this.config.publicBaseUrl)
|
|
73
|
+
return `${this.config.publicBaseUrl.replace(/\/$/, '')}/${this.prefix}${key}`
|
|
74
|
+
return `https://storage.googleapis.com/${this.config.bucket}/${this.prefix}${key}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async signedUrl(key: string, options?: NixxieSignedUrlOptions): Promise<string> {
|
|
78
|
+
const [url] = await this.file(key).getSignedUrl({
|
|
79
|
+
action: options?.operation === 'put' ? 'write' : 'read',
|
|
80
|
+
expires: Date.now() + (options?.expiresIn ?? 900) * 1000,
|
|
81
|
+
contentType: options?.operation === 'put' ? options?.contentType : undefined,
|
|
82
|
+
})
|
|
83
|
+
return url as string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async list(prefix = ''): Promise<string[]> {
|
|
87
|
+
const [files] = await this.bucket.getFiles({ prefix: `${this.prefix}${prefix}` })
|
|
88
|
+
return files
|
|
89
|
+
.map((f: any) => f.name as string)
|
|
90
|
+
.map((k: string) => (this.prefix && k.startsWith(this.prefix) ? k.slice(this.prefix.length) : k))
|
|
91
|
+
}
|
|
92
|
+
}
|