@adminforth/storage-adapter-local 1.0.2
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 +21 -0
- package/dist/index.js +390 -0
- package/dist/types.js +1 -0
- package/index.ts +440 -0
- package/package.json +28 -0
- package/tsconfig.json +12 -0
- package/types.ts +3 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Devforth.io
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import fs from "fs/promises";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import crypto from "crypto";
|
|
13
|
+
import { createWriteStream } from 'fs';
|
|
14
|
+
import { Level } from 'level';
|
|
15
|
+
class AdminForthStorageAdapterLocalFilesystem {
|
|
16
|
+
constructor(options) {
|
|
17
|
+
this.options = options;
|
|
18
|
+
}
|
|
19
|
+
presignUrl(urlPath, expiresIn, payload = {}) {
|
|
20
|
+
const expires = Math.floor(Date.now() / 1000) + expiresIn;
|
|
21
|
+
const params = new URLSearchParams(Object.assign(Object.assign({}, payload), { expires: expires.toString(), signature: this.sign(urlPath, expires, payload) }));
|
|
22
|
+
return `${urlPath}?${params.toString()}`;
|
|
23
|
+
}
|
|
24
|
+
sign(urlPath, expires, payload = {}) {
|
|
25
|
+
const hmac = crypto.createHmac("sha256", this.options.signingSecret);
|
|
26
|
+
hmac.update(urlPath);
|
|
27
|
+
hmac.update(expires.toString());
|
|
28
|
+
hmac.update(JSON.stringify(payload));
|
|
29
|
+
return hmac.digest("hex");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* This method should return the presigned URL for the given key capable of upload (adapter user will call PUT multipart form data to this URL within expiresIn seconds after link generation).
|
|
33
|
+
* By default file which will be uploaded on PUT should be marked for deletion. So if during 24h it is not marked for not deletion, it adapter should delete it forever.
|
|
34
|
+
* The PUT method should fail if the file already exists.
|
|
35
|
+
*
|
|
36
|
+
* Adapter user will always pass next parameters to the method:
|
|
37
|
+
* @param key - The key of the file to be uploaded e.g. "uploads/file.txt"
|
|
38
|
+
* @param expiresIn - The expiration time in seconds for the presigned URL
|
|
39
|
+
* @param contentType - The content type of the file to be uploaded, e.g. "image/png"
|
|
40
|
+
*
|
|
41
|
+
* @returns A promise that resolves to an object containing the upload URL and any extra parameters which should be sent with PUT multipart form data
|
|
42
|
+
*/
|
|
43
|
+
getUploadSignedUrl(key_1, contentType_1) {
|
|
44
|
+
return __awaiter(this, arguments, void 0, function* (key, contentType, expiresIn = 3600) {
|
|
45
|
+
const urlPath = `${this.expressBase}/${key}`;
|
|
46
|
+
return {
|
|
47
|
+
uploadUrl: this.presignUrl(urlPath, expiresIn, { contentType }),
|
|
48
|
+
uploadExtraParams: {}
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* This method should return the URL for the given key capable of download (200 GET request with response body or 200 HEAD request without response body).
|
|
54
|
+
* If adapter configured to store objects publically, this method should return the public URL of the file.
|
|
55
|
+
* If adapter configured to no allow public storing of images, this method should return the presigned URL for the file.
|
|
56
|
+
*
|
|
57
|
+
* @param key - The key of the file to be downloaded e.g. "uploads/file.txt"
|
|
58
|
+
* @param expiresIn - The expiration time in seconds for the presigned URL
|
|
59
|
+
*/
|
|
60
|
+
getDownloadUrl(key_1) {
|
|
61
|
+
return __awaiter(this, arguments, void 0, function* (key, _expiresIn = 3600) {
|
|
62
|
+
const urlPath = `${this.expressBase}/${key}`;
|
|
63
|
+
if (this.options.mode === "public") {
|
|
64
|
+
return urlPath;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
return this.presignUrl(key, _expiresIn);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
markKeyForDeletation(key) {
|
|
72
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
73
|
+
const metadata = yield this.metadataDb.get(key).catch((e) => {
|
|
74
|
+
console.error(`Could not read metadata from db: ${e}`);
|
|
75
|
+
throw new Error(`Could not read metadata from db: ${e}`);
|
|
76
|
+
});
|
|
77
|
+
if (!metadata) {
|
|
78
|
+
throw new Error(`Metadata for key ${key} not found`);
|
|
79
|
+
}
|
|
80
|
+
const metadataParsed = JSON.parse(metadata);
|
|
81
|
+
try {
|
|
82
|
+
yield this.candidatesForDeletionDb.get(key);
|
|
83
|
+
// if key already exists, do nothing
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
// if key does not exist, continue
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
yield this.candidatesForDeletionDb.put(key, metadataParsed.createdAt);
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
console.error(`Could not write metadata to db: ${e}`);
|
|
94
|
+
throw new Error(`Could not write metadata to db: ${e}`);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* This method should mark the file for deletion.
|
|
100
|
+
* If file is marked for delation and exists more then 24h (since creation date) it should be deleted.
|
|
101
|
+
* This method should work even if the file does not exist yet (e.g. only presigned URL was generated).
|
|
102
|
+
* @param key - The key of the file to be uploaded e.g. "uploads/file.txt"
|
|
103
|
+
*/
|
|
104
|
+
markKeyForNotDeletation(key) {
|
|
105
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
106
|
+
try {
|
|
107
|
+
// if key exists, delete it
|
|
108
|
+
yield this.candidatesForDeletionDb.del(key);
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
// if key does not exist, do nothing
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
setupLifecycle(userUniqueIntanceId) {
|
|
116
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
117
|
+
if (!this.options.fileSystemFolder) {
|
|
118
|
+
throw new Error("fileSystemFolder is not set in the options");
|
|
119
|
+
}
|
|
120
|
+
if (!this.options.signingSecret) {
|
|
121
|
+
throw new Error("signingSecret is not set in the options");
|
|
122
|
+
}
|
|
123
|
+
// check if folder exists and try to create it if not
|
|
124
|
+
// if it is not possible to create the folder, throw an error
|
|
125
|
+
try {
|
|
126
|
+
yield fs.mkdir(this.options.fileSystemFolder, { recursive: true });
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
throw new Error(`Could not create folder ${this.options.fileSystemFolder}: ${e}`);
|
|
130
|
+
}
|
|
131
|
+
// check if folder is writable
|
|
132
|
+
try {
|
|
133
|
+
yield fs.access(this.options.fileSystemFolder, fs.constants.W_OK);
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
throw new Error(`fileSystemFolder folder ${this.options.fileSystemFolder} is not writable: ${e}`);
|
|
137
|
+
}
|
|
138
|
+
// check if folder is readable
|
|
139
|
+
try {
|
|
140
|
+
yield fs.access(this.options.fileSystemFolder, fs.constants.R_OK);
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
throw new Error(`fileSystemFolder folder ${this.options.fileSystemFolder} is not readable: ${e}`);
|
|
144
|
+
}
|
|
145
|
+
this.metadataDb = new Level(path.join(this.options.fileSystemFolder, userUniqueIntanceId, 'metadata'));
|
|
146
|
+
this.candidatesForDeletionDb = new Level(path.join(this.options.fileSystemFolder, userUniqueIntanceId, 'candidatesForDeletion'));
|
|
147
|
+
const expressInstance = global.adminforth.express.expressApp;
|
|
148
|
+
const prefix = global.adminforth.config.baseUrl || '/';
|
|
149
|
+
const slashedPrefix = prefix.endsWith('/') ? prefix : `${prefix}/`;
|
|
150
|
+
this.adminforthSlashedPrefix = slashedPrefix;
|
|
151
|
+
if (!this.options.adminServeBaseUrl) {
|
|
152
|
+
this.expressBase = `${slashedPrefix}uploaded-static/${userUniqueIntanceId}`;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
if (AdminForthStorageAdapterLocalFilesystem.registredPrexises.includes(this.options.adminServeBaseUrl)) {
|
|
156
|
+
throw new Error(`adminServeBaseUrl ${this.options.adminServeBaseUrl} already registered, by another instance of local filesystem adapter.
|
|
157
|
+
Each adapter instahce should have unique adminServeBaseUrl by design.
|
|
158
|
+
`);
|
|
159
|
+
}
|
|
160
|
+
AdminForthStorageAdapterLocalFilesystem.registredPrexises.push(this.expressBase);
|
|
161
|
+
this.expressBase = `${slashedPrefix}${this.options.adminServeBaseUrl}`;
|
|
162
|
+
}
|
|
163
|
+
// add express PUT endpoint for uploading files
|
|
164
|
+
expressInstance.put(`${this.expressBase}/*`, (req, res) => __awaiter(this, void 0, void 0, function* () {
|
|
165
|
+
const key = req.params[0];
|
|
166
|
+
// get content type from headers
|
|
167
|
+
const contentType = req.headers["content-type"];
|
|
168
|
+
if (!contentType) {
|
|
169
|
+
return res.status(400).send("Content type is required");
|
|
170
|
+
}
|
|
171
|
+
const filePath = path.resolve(this.options.fileSystemFolder, key);
|
|
172
|
+
// Ensure filePath is within fileSystemFolder
|
|
173
|
+
const basePath = path.resolve(this.options.fileSystemFolder);
|
|
174
|
+
if (!filePath.startsWith(basePath + path.sep)) {
|
|
175
|
+
return res.status(400).send("Invalid key, access denied");
|
|
176
|
+
}
|
|
177
|
+
//verify presigned URL
|
|
178
|
+
const expires = parseInt(req.query.expires);
|
|
179
|
+
const signature = req.query.signature;
|
|
180
|
+
const payload = {
|
|
181
|
+
contentType: contentType,
|
|
182
|
+
};
|
|
183
|
+
console.log(`👐👐👐 verify sign for ${key}|${expires}|${JSON.stringify(payload)}`);
|
|
184
|
+
const expectedSignature = this.sign(`${this.expressBase}/${key}`, expires, payload);
|
|
185
|
+
if (signature !== expectedSignature) {
|
|
186
|
+
return res.status(403).send("Invalid signature");
|
|
187
|
+
}
|
|
188
|
+
if (Date.now() / 1000 > expires) {
|
|
189
|
+
return res.status(403).send("Signature expired");
|
|
190
|
+
}
|
|
191
|
+
// check if content type is valid
|
|
192
|
+
if (contentType !== req.headers["content-type"]) {
|
|
193
|
+
return res.status(400).send("Invalid content type");
|
|
194
|
+
}
|
|
195
|
+
// check if file already exists
|
|
196
|
+
try {
|
|
197
|
+
yield fs.access(filePath);
|
|
198
|
+
return res.status(409).send("File already exists");
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
// file does not exist, continue
|
|
202
|
+
}
|
|
203
|
+
// create folder if it does not exist
|
|
204
|
+
const folderPath = path.dirname(filePath);
|
|
205
|
+
try {
|
|
206
|
+
yield fs.mkdir(folderPath, { recursive: true });
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
return res.status(500).send(`Could not create folder ${folderPath}: ${e}`);
|
|
210
|
+
}
|
|
211
|
+
// write file to disk
|
|
212
|
+
const writeStream = createWriteStream(filePath);
|
|
213
|
+
req.pipe(writeStream);
|
|
214
|
+
writeStream.on("finish", () => {
|
|
215
|
+
// write metadata to db
|
|
216
|
+
this.metadataDb.put(key, JSON.stringify({
|
|
217
|
+
contentType: contentType,
|
|
218
|
+
createdAt: +Date.now(),
|
|
219
|
+
size: writeStream.bytesWritten,
|
|
220
|
+
})).catch((e) => {
|
|
221
|
+
console.error(`Could not write metadata to db: ${e}`);
|
|
222
|
+
throw new Error(`Could not write metadata to db: ${e}`);
|
|
223
|
+
});
|
|
224
|
+
this.markKeyForDeletation(key);
|
|
225
|
+
res.status(200).send("File uploaded");
|
|
226
|
+
});
|
|
227
|
+
}));
|
|
228
|
+
console.log(`🎉🎉🎉 registring get endpoint for ${this.expressBase}/*`);
|
|
229
|
+
// add express GET endpoint for downloading files
|
|
230
|
+
expressInstance.get(`${this.expressBase}/*`, (req, res) => __awaiter(this, void 0, void 0, function* () {
|
|
231
|
+
console.log(`🎉🎉🎉 GET ${req.url}`, res, typeof res, Object.keys(res));
|
|
232
|
+
const key = req.params[0];
|
|
233
|
+
const filePath = path.resolve(this.options.fileSystemFolder, key);
|
|
234
|
+
// Ensure filePath is within fileSystemFolder
|
|
235
|
+
const basePath = path.resolve(this.options.fileSystemFolder);
|
|
236
|
+
if (!filePath.startsWith(basePath + path.sep)) {
|
|
237
|
+
return res.status(400).send("Invalid key, access denied");
|
|
238
|
+
}
|
|
239
|
+
// check if file exists
|
|
240
|
+
try {
|
|
241
|
+
yield fs.access(filePath);
|
|
242
|
+
}
|
|
243
|
+
catch (e) {
|
|
244
|
+
return res.status(404).send("File not found");
|
|
245
|
+
}
|
|
246
|
+
// add metadata to response headers
|
|
247
|
+
const metadata = yield this.metadataDb.get(key).catch((e) => {
|
|
248
|
+
throw new Error(`Could not read metadata for ${key} from db: ${e}`);
|
|
249
|
+
});
|
|
250
|
+
if (!metadata) {
|
|
251
|
+
return res.status(404).send(`Metadata for ${key} not found`);
|
|
252
|
+
}
|
|
253
|
+
const metadataParsed = JSON.parse(metadata);
|
|
254
|
+
// send file to client
|
|
255
|
+
res.sendFile(filePath, {
|
|
256
|
+
headers: {
|
|
257
|
+
"Content-Type": metadataParsed.contentType,
|
|
258
|
+
"Content-Length": metadataParsed.size,
|
|
259
|
+
"Last-Modified": new Date(metadataParsed.createdAt).toUTCString(),
|
|
260
|
+
"ETag": crypto.createHash("md5").update(metadata).digest("hex"),
|
|
261
|
+
},
|
|
262
|
+
}, (err) => {
|
|
263
|
+
if (err) {
|
|
264
|
+
console.error(`Could not send file ${filePath}: ${err}`);
|
|
265
|
+
res.status(500).send("Could not send file");
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}));
|
|
269
|
+
this.putLastListenerToTheBeginningOfTheStack(expressInstance);
|
|
270
|
+
// add HEAD endpoint for returning file metadata
|
|
271
|
+
expressInstance.head(`${this.expressBase}/*`, (req, res) => __awaiter(this, void 0, void 0, function* () {
|
|
272
|
+
const key = req.params[0];
|
|
273
|
+
const filePath = path.resolve(this.options.fileSystemFolder, key);
|
|
274
|
+
// Ensure filePath is within fileSystemFolder
|
|
275
|
+
const basePath = path.resolve(this.options.fileSystemFolder);
|
|
276
|
+
if (!filePath.startsWith(basePath + path.sep)) {
|
|
277
|
+
return res.status(400).send("Invalid key, access denied");
|
|
278
|
+
}
|
|
279
|
+
// check if file exists
|
|
280
|
+
try {
|
|
281
|
+
yield fs.access(filePath);
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
return res.status(404).send("File not found");
|
|
285
|
+
}
|
|
286
|
+
// add metadata to response headers
|
|
287
|
+
const metadata = yield this.metadataDb.get(key).catch((e) => {
|
|
288
|
+
throw new Error(`Could not read metadata for ${key} from db: ${e}`);
|
|
289
|
+
});
|
|
290
|
+
if (!metadata) {
|
|
291
|
+
return res.status(404).send(`Metadata for ${key} not found`);
|
|
292
|
+
}
|
|
293
|
+
const metadataParsed = JSON.parse(metadata);
|
|
294
|
+
res.setHeader("Content-Type", metadataParsed.contentType);
|
|
295
|
+
res.setHeader("Content-Length", metadataParsed.size);
|
|
296
|
+
res.setHeader("Last-Modified", new Date(metadataParsed.createdAt).toUTCString());
|
|
297
|
+
res.setHeader("ETag", crypto.createHash("md5").update(metadata).digest("hex"));
|
|
298
|
+
}));
|
|
299
|
+
this.putLastListenerToTheBeginningOfTheStack(expressInstance);
|
|
300
|
+
// run scheduler every 10 minutes to delete files marked for deletion
|
|
301
|
+
setInterval(() => __awaiter(this, void 0, void 0, function* () {
|
|
302
|
+
const now = +Date.now();
|
|
303
|
+
const keys = yield this.candidatesForDeletionDb.keys().all();
|
|
304
|
+
for (const key of keys) {
|
|
305
|
+
const createdAt = yield this.candidatesForDeletionDb.get(key).catch((e) => {
|
|
306
|
+
console.error(`Could not read metadata from db: ${e}`);
|
|
307
|
+
throw new Error(`Could not read metadata from db: ${e}`);
|
|
308
|
+
});
|
|
309
|
+
if (now - +createdAt > 24 * 60 * 60 * 1000) {
|
|
310
|
+
// delete file
|
|
311
|
+
try {
|
|
312
|
+
yield fs.unlink(path.resolve(this.options.fileSystemFolder, key));
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
console.error(`Could not delete file ${key}: ${e}`);
|
|
316
|
+
throw new Error(`Could not delete file ${key}: ${e}`);
|
|
317
|
+
}
|
|
318
|
+
// delete metadata
|
|
319
|
+
try {
|
|
320
|
+
yield this.metadataDb.del(key);
|
|
321
|
+
}
|
|
322
|
+
catch (e) {
|
|
323
|
+
console.error(`Could not delete metadata from db: ${e}`);
|
|
324
|
+
throw new Error(`Could not delete metadata from db: ${e}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}), 10 * 60 * 1000); // every 10 minutes
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
objectCanBeAccesedPublicly() {
|
|
332
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
333
|
+
return this.options.mode === "public";
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
putLastListenerToTheBeginningOfTheStack(expressInstance) {
|
|
337
|
+
// since adminforth might already registred /* endpoint we need to reorder the routes
|
|
338
|
+
const stack = expressInstance._router.stack;
|
|
339
|
+
const adpaterListnerLayer = stack.pop(); // route is last, just pop it
|
|
340
|
+
// find route with ${this.adminforthSlashedPrefix}assets/*
|
|
341
|
+
const wildcardIndex = stack.findIndex((layer) => {
|
|
342
|
+
return layer.route && layer.route.path === `${this.adminforthSlashedPrefix}assets/*`;
|
|
343
|
+
});
|
|
344
|
+
if (wildcardIndex === -1) {
|
|
345
|
+
// if not found, just push it to the end, e.g. if discover databse and this method executed before
|
|
346
|
+
// adminforth registered the wildcard route
|
|
347
|
+
stack.push(adpaterListnerLayer);
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
stack.splice(wildcardIndex, 0, adpaterListnerLayer); // insert before wildcard
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* This method should return the key as a data URL (base64 encoded string).
|
|
355
|
+
* @param key - The key of the file to be converted to a data URL
|
|
356
|
+
* @returns A promise that resolves to a string containing the data URL
|
|
357
|
+
*/
|
|
358
|
+
getKeyAsDataURL(key) {
|
|
359
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
360
|
+
const filePath = path.resolve(this.options.fileSystemFolder, key);
|
|
361
|
+
// Ensure filePath is within fileSystemFolder
|
|
362
|
+
const basePath = path.resolve(this.options.fileSystemFolder);
|
|
363
|
+
if (!filePath.startsWith(basePath + path.sep)) {
|
|
364
|
+
throw new Error("Invalid key, access denied");
|
|
365
|
+
}
|
|
366
|
+
// check if file exists
|
|
367
|
+
try {
|
|
368
|
+
yield fs.access(filePath);
|
|
369
|
+
}
|
|
370
|
+
catch (e) {
|
|
371
|
+
throw new Error("File not found");
|
|
372
|
+
}
|
|
373
|
+
// read file and convert to base64
|
|
374
|
+
const fileBuffer = yield fs.readFile(filePath);
|
|
375
|
+
const base64 = fileBuffer.toString("base64");
|
|
376
|
+
const metadata = yield this.metadataDb.get(key).catch((e) => {
|
|
377
|
+
console.error(`Could not read metadata from db: ${e}`);
|
|
378
|
+
throw new Error(`Could not read metadata from db: ${e}`);
|
|
379
|
+
});
|
|
380
|
+
if (!metadata) {
|
|
381
|
+
throw new Error(`Metadata for key ${key} not found`);
|
|
382
|
+
}
|
|
383
|
+
const metadataParsed = JSON.parse(metadata);
|
|
384
|
+
const dataUrl = `data:${metadataParsed.contentType};base64,${base64}`;
|
|
385
|
+
return dataUrl;
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
AdminForthStorageAdapterLocalFilesystem.registredPrexises = [];
|
|
390
|
+
export default AdminForthStorageAdapterLocalFilesystem;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/index.ts
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import AdminForth, { StorageAdapter } from "adminforth";
|
|
4
|
+
import crypto from "crypto";
|
|
5
|
+
import { createWriteStream } from 'fs';
|
|
6
|
+
import { Level } from 'level';
|
|
7
|
+
import { Express } from "express";
|
|
8
|
+
|
|
9
|
+
declare global {
|
|
10
|
+
var adminforth: AdminForth;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface StorageLocalFilesystemOptions {
|
|
14
|
+
fileSystemFolder: string; // folder where files will be stored
|
|
15
|
+
mode: "public" | "private"; // public if all files should be accessible from the web, private only if could be accessed by temporary presigned links
|
|
16
|
+
signingSecret: string; // secret used to generate presigned URLs
|
|
17
|
+
adminServeBaseUrl?: string; // base URL for serving files e.g. static/uploads. If not defined will be generated automatically
|
|
18
|
+
// please note that is adminforth base URL is set, files will be available on `${adminforth.config.baseUrl}/${adminServeBaseUrl}/{key}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default class AdminForthStorageAdapterLocalFilesystem implements StorageAdapter {
|
|
22
|
+
static registredPrexises: string[] = [];
|
|
23
|
+
|
|
24
|
+
private options: StorageLocalFilesystemOptions;
|
|
25
|
+
private expressBase: string;
|
|
26
|
+
private adminforthSlashedPrefix: string; // slashed prefix of the base URL
|
|
27
|
+
|
|
28
|
+
private metadataDb: Level;
|
|
29
|
+
private candidatesForDeletionDb: Level;
|
|
30
|
+
|
|
31
|
+
constructor(options: StorageLocalFilesystemOptions) {
|
|
32
|
+
this.options = options;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
presignUrl(urlPath: string, expiresIn: number, payload: Record<string, string> = {}): string {
|
|
36
|
+
const expires = Math.floor(Date.now() / 1000) + expiresIn;
|
|
37
|
+
|
|
38
|
+
const params = new URLSearchParams({
|
|
39
|
+
...payload,
|
|
40
|
+
expires: expires.toString(),
|
|
41
|
+
signature: this.sign(urlPath, expires, payload),
|
|
42
|
+
});
|
|
43
|
+
return `${urlPath}?${params.toString()}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
sign(urlPath: string, expires: number, payload: Record<string, string> = {}): string {
|
|
47
|
+
const hmac = crypto.createHmac("sha256", this.options.signingSecret);
|
|
48
|
+
hmac.update(urlPath);
|
|
49
|
+
hmac.update(expires.toString());
|
|
50
|
+
hmac.update(JSON.stringify(payload));
|
|
51
|
+
return hmac.digest("hex");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* This method should return the presigned URL for the given key capable of upload (adapter user will call PUT multipart form data to this URL within expiresIn seconds after link generation).
|
|
57
|
+
* By default file which will be uploaded on PUT should be marked for deletion. So if during 24h it is not marked for not deletion, it adapter should delete it forever.
|
|
58
|
+
* The PUT method should fail if the file already exists.
|
|
59
|
+
*
|
|
60
|
+
* Adapter user will always pass next parameters to the method:
|
|
61
|
+
* @param key - The key of the file to be uploaded e.g. "uploads/file.txt"
|
|
62
|
+
* @param expiresIn - The expiration time in seconds for the presigned URL
|
|
63
|
+
* @param contentType - The content type of the file to be uploaded, e.g. "image/png"
|
|
64
|
+
*
|
|
65
|
+
* @returns A promise that resolves to an object containing the upload URL and any extra parameters which should be sent with PUT multipart form data
|
|
66
|
+
*/
|
|
67
|
+
async getUploadSignedUrl(
|
|
68
|
+
key: string,
|
|
69
|
+
contentType: string,
|
|
70
|
+
expiresIn = 3600
|
|
71
|
+
): Promise<{ uploadUrl: string; uploadExtraParams: Record<string, string> }> {
|
|
72
|
+
const urlPath = `${this.expressBase}/${key}`;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
uploadUrl: this.presignUrl(urlPath, expiresIn, { contentType }),
|
|
76
|
+
uploadExtraParams: {}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* This method should return the URL for the given key capable of download (200 GET request with response body or 200 HEAD request without response body).
|
|
83
|
+
* If adapter configured to store objects publically, this method should return the public URL of the file.
|
|
84
|
+
* If adapter configured to no allow public storing of images, this method should return the presigned URL for the file.
|
|
85
|
+
*
|
|
86
|
+
* @param key - The key of the file to be downloaded e.g. "uploads/file.txt"
|
|
87
|
+
* @param expiresIn - The expiration time in seconds for the presigned URL
|
|
88
|
+
*/
|
|
89
|
+
async getDownloadUrl(key: string, _expiresIn = 3600): Promise<string> {
|
|
90
|
+
const urlPath = `${this.expressBase}/${key}`;
|
|
91
|
+
if (this.options.mode === "public") {
|
|
92
|
+
return urlPath;
|
|
93
|
+
} else {
|
|
94
|
+
return this.presignUrl(key, _expiresIn);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async markKeyForDeletation(key: string): Promise<void> {
|
|
99
|
+
const metadata = await this.metadataDb.get(key).catch((e) => {
|
|
100
|
+
console.error(`Could not read metadata from db: ${e}`);
|
|
101
|
+
throw new Error(`Could not read metadata from db: ${e}`);
|
|
102
|
+
});
|
|
103
|
+
if (!metadata) {
|
|
104
|
+
throw new Error(`Metadata for key ${key} not found`);
|
|
105
|
+
}
|
|
106
|
+
const metadataParsed = JSON.parse(metadata);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
await this.candidatesForDeletionDb.get(key);
|
|
110
|
+
// if key already exists, do nothing
|
|
111
|
+
return;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
// if key does not exist, continue
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
await this.candidatesForDeletionDb.put(key, metadataParsed.createdAt)
|
|
117
|
+
} catch (e) {
|
|
118
|
+
console.error(`Could not write metadata to db: ${e}`);
|
|
119
|
+
throw new Error(`Could not write metadata to db: ${e}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* This method should mark the file for deletion.
|
|
125
|
+
* If file is marked for delation and exists more then 24h (since creation date) it should be deleted.
|
|
126
|
+
* This method should work even if the file does not exist yet (e.g. only presigned URL was generated).
|
|
127
|
+
* @param key - The key of the file to be uploaded e.g. "uploads/file.txt"
|
|
128
|
+
*/
|
|
129
|
+
async markKeyForNotDeletation(key: string): Promise<void> {
|
|
130
|
+
try {
|
|
131
|
+
// if key exists, delete it
|
|
132
|
+
await this.candidatesForDeletionDb.del(key);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
// if key does not exist, do nothing
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async setupLifecycle(userUniqueIntanceId): Promise<void> {
|
|
139
|
+
|
|
140
|
+
if (!this.options.fileSystemFolder) {
|
|
141
|
+
throw new Error("fileSystemFolder is not set in the options");
|
|
142
|
+
}
|
|
143
|
+
if (!this.options.signingSecret) {
|
|
144
|
+
throw new Error("signingSecret is not set in the options");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// check if folder exists and try to create it if not
|
|
148
|
+
// if it is not possible to create the folder, throw an error
|
|
149
|
+
try {
|
|
150
|
+
await fs.mkdir(this.options.fileSystemFolder, { recursive: true });
|
|
151
|
+
} catch (e) {
|
|
152
|
+
throw new Error(`Could not create folder ${this.options.fileSystemFolder}: ${e}`);
|
|
153
|
+
}
|
|
154
|
+
// check if folder is writable
|
|
155
|
+
try {
|
|
156
|
+
await fs.access(this.options.fileSystemFolder, fs.constants.W_OK);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
throw new Error(`fileSystemFolder folder ${this.options.fileSystemFolder} is not writable: ${e}`);
|
|
159
|
+
}
|
|
160
|
+
// check if folder is readable
|
|
161
|
+
try {
|
|
162
|
+
await fs.access(this.options.fileSystemFolder, fs.constants.R_OK);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
throw new Error(`fileSystemFolder folder ${this.options.fileSystemFolder} is not readable: ${e}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
this.metadataDb = new Level(path.join(this.options.fileSystemFolder, userUniqueIntanceId, 'metadata'));
|
|
168
|
+
|
|
169
|
+
this.candidatesForDeletionDb = new Level(path.join(this.options.fileSystemFolder, userUniqueIntanceId, 'candidatesForDeletion'));
|
|
170
|
+
|
|
171
|
+
const expressInstance: Express = global.adminforth.express.expressApp;
|
|
172
|
+
const prefix = global.adminforth.config.baseUrl || '/';
|
|
173
|
+
|
|
174
|
+
const slashedPrefix = prefix.endsWith('/') ? prefix : `${prefix}/`;
|
|
175
|
+
this.adminforthSlashedPrefix = slashedPrefix;
|
|
176
|
+
if (!this.options.adminServeBaseUrl) {
|
|
177
|
+
this.expressBase = `${slashedPrefix}uploaded-static/${userUniqueIntanceId}`
|
|
178
|
+
} else {
|
|
179
|
+
if (AdminForthStorageAdapterLocalFilesystem.registredPrexises.includes(this.options.adminServeBaseUrl)) {
|
|
180
|
+
throw new Error(`adminServeBaseUrl ${this.options.adminServeBaseUrl} already registered, by another instance of local filesystem adapter.
|
|
181
|
+
Each adapter instahce should have unique adminServeBaseUrl by design.
|
|
182
|
+
`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
AdminForthStorageAdapterLocalFilesystem.registredPrexises.push(this.expressBase);
|
|
186
|
+
this.expressBase = `${slashedPrefix}${this.options.adminServeBaseUrl}`;
|
|
187
|
+
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
// add express PUT endpoint for uploading files
|
|
192
|
+
expressInstance.put(`${this.expressBase}/*`, async (req: any, res: any) => {
|
|
193
|
+
const key = req.params[0];
|
|
194
|
+
|
|
195
|
+
// get content type from headers
|
|
196
|
+
const contentType = req.headers["content-type"] as string;
|
|
197
|
+
if (!contentType) {
|
|
198
|
+
return res.status(400).send("Content type is required");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const filePath = path.resolve(this.options.fileSystemFolder, key);
|
|
202
|
+
|
|
203
|
+
// Ensure filePath is within fileSystemFolder
|
|
204
|
+
const basePath = path.resolve(this.options.fileSystemFolder);
|
|
205
|
+
if (!filePath.startsWith(basePath + path.sep)) {
|
|
206
|
+
return res.status(400).send("Invalid key, access denied");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
//verify presigned URL
|
|
210
|
+
const expires = parseInt(req.query.expires as string);
|
|
211
|
+
const signature = req.query.signature as string;
|
|
212
|
+
const payload = {
|
|
213
|
+
contentType: contentType,
|
|
214
|
+
}
|
|
215
|
+
console.log(`👐👐👐 verify sign for ${key}|${expires}|${JSON.stringify(payload)}`)
|
|
216
|
+
|
|
217
|
+
const expectedSignature = this.sign(
|
|
218
|
+
`${this.expressBase}/${key}`, expires, payload);
|
|
219
|
+
if (signature !== expectedSignature) {
|
|
220
|
+
return res.status(403).send("Invalid signature");
|
|
221
|
+
}
|
|
222
|
+
if (Date.now() / 1000 > expires) {
|
|
223
|
+
return res.status(403).send("Signature expired");
|
|
224
|
+
}
|
|
225
|
+
// check if content type is valid
|
|
226
|
+
if (contentType !== req.headers["content-type"]) {
|
|
227
|
+
return res.status(400).send("Invalid content type");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// check if file already exists
|
|
231
|
+
try {
|
|
232
|
+
await fs.access(filePath);
|
|
233
|
+
return res.status(409).send("File already exists");
|
|
234
|
+
} catch (e) {
|
|
235
|
+
// file does not exist, continue
|
|
236
|
+
}
|
|
237
|
+
// create folder if it does not exist
|
|
238
|
+
const folderPath = path.dirname(filePath);
|
|
239
|
+
try {
|
|
240
|
+
await fs.mkdir(folderPath, { recursive: true });
|
|
241
|
+
} catch (e) {
|
|
242
|
+
return res.status(500).send(`Could not create folder ${folderPath}: ${e}`);
|
|
243
|
+
}
|
|
244
|
+
// write file to disk
|
|
245
|
+
const writeStream = createWriteStream(filePath);
|
|
246
|
+
req.pipe(writeStream);
|
|
247
|
+
writeStream.on("finish", () => {
|
|
248
|
+
// write metadata to db
|
|
249
|
+
this.metadataDb.put(key,
|
|
250
|
+
JSON.stringify({
|
|
251
|
+
contentType: contentType,
|
|
252
|
+
createdAt: +Date.now(),
|
|
253
|
+
size: writeStream.bytesWritten,
|
|
254
|
+
})
|
|
255
|
+
).catch((e) => {
|
|
256
|
+
console.error(`Could not write metadata to db: ${e}`);
|
|
257
|
+
throw new Error(`Could not write metadata to db: ${e}`);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
this.markKeyForDeletation(key);
|
|
261
|
+
|
|
262
|
+
res.status(200).send("File uploaded");
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
console.log(`🎉🎉🎉 registring get endpoint for ${this.expressBase}/*`)
|
|
267
|
+
// add express GET endpoint for downloading files
|
|
268
|
+
expressInstance.get(`${this.expressBase}/*`, async (req: any, res: any) => {
|
|
269
|
+
console.log(`🎉🎉🎉 GET ${req.url}`, res, typeof res, Object.keys(res));
|
|
270
|
+
const key = req.params[0];
|
|
271
|
+
const filePath = path.resolve(this.options.fileSystemFolder, key);
|
|
272
|
+
|
|
273
|
+
// Ensure filePath is within fileSystemFolder
|
|
274
|
+
const basePath = path.resolve(this.options.fileSystemFolder);
|
|
275
|
+
if (!filePath.startsWith(basePath + path.sep)) {
|
|
276
|
+
return res.status(400).send("Invalid key, access denied");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// check if file exists
|
|
280
|
+
try {
|
|
281
|
+
await fs.access(filePath);
|
|
282
|
+
} catch (e) {
|
|
283
|
+
return res.status(404).send("File not found");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// add metadata to response headers
|
|
287
|
+
const metadata = await this.metadataDb.get(key).catch((e) => {
|
|
288
|
+
throw new Error(`Could not read metadata for ${key} from db: ${e}`);
|
|
289
|
+
});
|
|
290
|
+
if (!metadata) {
|
|
291
|
+
return res.status(404).send(`Metadata for ${key} not found`);
|
|
292
|
+
}
|
|
293
|
+
const metadataParsed = JSON.parse(metadata);
|
|
294
|
+
// send file to client
|
|
295
|
+
res.sendFile(
|
|
296
|
+
filePath,
|
|
297
|
+
{
|
|
298
|
+
headers: {
|
|
299
|
+
"Content-Type": metadataParsed.contentType,
|
|
300
|
+
"Content-Length": metadataParsed.size,
|
|
301
|
+
"Last-Modified": new Date(metadataParsed.createdAt).toUTCString(),
|
|
302
|
+
"ETag": crypto.createHash("md5").update(metadata).digest("hex"),
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
(err) => {
|
|
306
|
+
if (err) {
|
|
307
|
+
console.error(`Could not send file ${filePath}: ${err}`);
|
|
308
|
+
res.status(500).send("Could not send file");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
this.putLastListenerToTheBeginningOfTheStack(expressInstance);
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
// add HEAD endpoint for returning file metadata
|
|
319
|
+
expressInstance.head(`${this.expressBase}/*`, async (req: any, res: any) => {
|
|
320
|
+
const key = req.params[0];
|
|
321
|
+
const filePath = path.resolve(this.options.fileSystemFolder, key);
|
|
322
|
+
|
|
323
|
+
// Ensure filePath is within fileSystemFolder
|
|
324
|
+
const basePath = path.resolve(this.options.fileSystemFolder);
|
|
325
|
+
if (!filePath.startsWith(basePath + path.sep)) {
|
|
326
|
+
return res.status(400).send("Invalid key, access denied");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// check if file exists
|
|
330
|
+
try {
|
|
331
|
+
await fs.access(filePath);
|
|
332
|
+
} catch (e) {
|
|
333
|
+
return res.status(404).send("File not found");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// add metadata to response headers
|
|
337
|
+
const metadata = await this.metadataDb.get(key).catch((e) => {
|
|
338
|
+
throw new Error(`Could not read metadata for ${key} from db: ${e}`);
|
|
339
|
+
});
|
|
340
|
+
if (!metadata) {
|
|
341
|
+
return res.status(404).send(`Metadata for ${key} not found`);
|
|
342
|
+
}
|
|
343
|
+
const metadataParsed = JSON.parse(metadata);
|
|
344
|
+
res.setHeader("Content-Type", metadataParsed.contentType);
|
|
345
|
+
res.setHeader("Content-Length", metadataParsed.size);
|
|
346
|
+
res.setHeader("Last-Modified", new Date(metadataParsed.createdAt).toUTCString());
|
|
347
|
+
res.setHeader("ETag", crypto.createHash("md5").update(metadata).digest("hex"));
|
|
348
|
+
});
|
|
349
|
+
this.putLastListenerToTheBeginningOfTheStack(expressInstance);
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
// run scheduler every 10 minutes to delete files marked for deletion
|
|
353
|
+
setInterval(async () => {
|
|
354
|
+
const now = +Date.now();
|
|
355
|
+
const keys = await this.candidatesForDeletionDb.keys().all();
|
|
356
|
+
for (const key of keys) {
|
|
357
|
+
const createdAt = await this.candidatesForDeletionDb.get(key).catch((e) => {
|
|
358
|
+
console.error(`Could not read metadata from db: ${e}`);
|
|
359
|
+
throw new Error(`Could not read metadata from db: ${e}`);
|
|
360
|
+
});
|
|
361
|
+
if (now - +createdAt > 24 * 60 * 60 * 1000) {
|
|
362
|
+
// delete file
|
|
363
|
+
try {
|
|
364
|
+
await fs.unlink(path.resolve(this.options.fileSystemFolder, key));
|
|
365
|
+
} catch (e) {
|
|
366
|
+
console.error(`Could not delete file ${key}: ${e}`);
|
|
367
|
+
throw new Error(`Could not delete file ${key}: ${e}`);
|
|
368
|
+
}
|
|
369
|
+
// delete metadata
|
|
370
|
+
try {
|
|
371
|
+
await this.metadataDb.del(key);
|
|
372
|
+
} catch (e) {
|
|
373
|
+
console.error(`Could not delete metadata from db: ${e}`);
|
|
374
|
+
throw new Error(`Could not delete metadata from db: ${e}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
, 10 * 60 * 1000); // every 10 minutes
|
|
380
|
+
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async objectCanBeAccesedPublicly(): Promise<boolean> {
|
|
384
|
+
return this.options.mode === "public";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
putLastListenerToTheBeginningOfTheStack(expressInstance) {
|
|
388
|
+
// since adminforth might already registred /* endpoint we need to reorder the routes
|
|
389
|
+
const stack = expressInstance._router.stack;
|
|
390
|
+
const adpaterListnerLayer = stack.pop(); // route is last, just pop it
|
|
391
|
+
// find route with ${this.adminforthSlashedPrefix}assets/*
|
|
392
|
+
const wildcardIndex = stack.findIndex((layer) => {
|
|
393
|
+
return layer.route && layer.route.path === `${this.adminforthSlashedPrefix}assets/*`;
|
|
394
|
+
});
|
|
395
|
+
if (wildcardIndex === -1) {
|
|
396
|
+
// if not found, just push it to the end, e.g. if discover databse and this method executed before
|
|
397
|
+
// adminforth registered the wildcard route
|
|
398
|
+
stack.push(adpaterListnerLayer);
|
|
399
|
+
} else {
|
|
400
|
+
stack.splice(wildcardIndex, 0, adpaterListnerLayer); // insert before wildcard
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* This method should return the key as a data URL (base64 encoded string).
|
|
406
|
+
* @param key - The key of the file to be converted to a data URL
|
|
407
|
+
* @returns A promise that resolves to a string containing the data URL
|
|
408
|
+
*/
|
|
409
|
+
async getKeyAsDataURL(key: string): Promise<string> {
|
|
410
|
+
const filePath = path.resolve(this.options.fileSystemFolder, key);
|
|
411
|
+
|
|
412
|
+
// Ensure filePath is within fileSystemFolder
|
|
413
|
+
const basePath = path.resolve(this.options.fileSystemFolder);
|
|
414
|
+
if (!filePath.startsWith(basePath + path.sep)) {
|
|
415
|
+
throw new Error("Invalid key, access denied");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// check if file exists
|
|
419
|
+
try {
|
|
420
|
+
await fs.access(filePath);
|
|
421
|
+
} catch (e) {
|
|
422
|
+
throw new Error("File not found");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// read file and convert to base64
|
|
426
|
+
const fileBuffer = await fs.readFile(filePath);
|
|
427
|
+
const base64 = fileBuffer.toString("base64");
|
|
428
|
+
const metadata = await this.metadataDb.get(key).catch((e) => {
|
|
429
|
+
console.error(`Could not read metadata from db: ${e}`);
|
|
430
|
+
throw new Error(`Could not read metadata from db: ${e}`);
|
|
431
|
+
});
|
|
432
|
+
if (!metadata) {
|
|
433
|
+
throw new Error(`Metadata for key ${key} not found`);
|
|
434
|
+
}
|
|
435
|
+
const metadataParsed = JSON.parse(metadata);
|
|
436
|
+
const dataUrl = `data:${metadataParsed.contentType};base64,${base64}`;
|
|
437
|
+
return dataUrl;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@adminforth/storage-adapter-local",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"types": "dist/index.d.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc && npm version patch",
|
|
9
|
+
"rollout": "npm run build && npm publish --access public",
|
|
10
|
+
"prepare": "npm link adminforth"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [],
|
|
13
|
+
"author": "",
|
|
14
|
+
"license": "ISC",
|
|
15
|
+
"description": "",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/devforth/adminforth-storage-adapter-local.git"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@types/express": "^4.17.21",
|
|
22
|
+
"@types/level": "^6.0.3",
|
|
23
|
+
"adminforth": "^1.21.0",
|
|
24
|
+
"express": "^4.21.2",
|
|
25
|
+
"level": "^10.0.0",
|
|
26
|
+
"typescript": "^5.8.2"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include*/
|
|
4
|
+
"module": "node16", /* Specify what module code is generated. */
|
|
5
|
+
"outDir": "./dist", /* Specify an output folder for all emitted files. */
|
|
6
|
+
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. */
|
|
7
|
+
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
|
8
|
+
"strict": false, /* Enable all strict type-checking options. */
|
|
9
|
+
"skipLibCheck": true, /* Skip type checking all .d.ts files. */
|
|
10
|
+
},
|
|
11
|
+
"exclude": ["node_modules", "dist", "custom"], /* Exclude files from compilation. */
|
|
12
|
+
}
|
package/types.ts
ADDED