@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 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
@@ -0,0 +1,3 @@
1
+ export interface AdapterOptions {
2
+ localPath: string;
3
+ }