snowbase 5.0.0 → 6.0.0
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/CHANGELOG.md +34 -0
- package/LICENSE +7 -0
- package/README.md +224 -31
- package/bin/snowbase.js +291 -0
- package/package.json +50 -16
- package/src/Snowbase.js +403 -0
- package/src/SnowbaseAsync.js +402 -0
- package/src/helpers/crypto.js +69 -0
- package/src/helpers/utils.js +234 -0
- package/src/helpers/utilsAsync.js +229 -0
- package/src/storages/FileStorage.js +170 -0
- package/src/storages/FileStorageAsync.js +167 -0
- package/.upm/store.json +0 -1
- package/index.js +0 -1
- package/src/Local.js +0 -45
- package/src/Server.js +0 -121
- package/src/index.js +0 -4
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { stat, mkdir, readdir, unlink, copyFile } from "node:fs/promises";
|
|
2
|
+
import { encrypt, decrypt } from "./helpers/crypto.js";
|
|
3
|
+
import { parsePath, log, getTargetFromData } from "./helpers/utilsAsync.js";
|
|
4
|
+
import FileStorageAsync from "./storages/FileStorageAsync.js";
|
|
5
|
+
import { resolve, join } from "path";
|
|
6
|
+
import { EventEmitter } from "events";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Snowbase database in JSON, with encription, logging and backup
|
|
10
|
+
* @class SnowbaseAsync
|
|
11
|
+
* @extends EventEmitter
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* import Snowbase from "snowbase/async";
|
|
15
|
+
*
|
|
16
|
+
* const db = new Snowbase({
|
|
17
|
+
* baseDir: "./database"// Default is './snowbase'
|
|
18
|
+
* encryption: true,
|
|
19
|
+
* secret: "password123",
|
|
20
|
+
* logging: true,
|
|
21
|
+
* backup: true
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* db.on("ready", () => {
|
|
25
|
+
* console.log("Database is ready");
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* db.on("error", (err) => {
|
|
29
|
+
* console.error(err);
|
|
30
|
+
* });
|
|
31
|
+
* await db.set("user/name", "Pedro");
|
|
32
|
+
* console.log(await db.get("user/name"))// Pedro
|
|
33
|
+
*/
|
|
34
|
+
export default class SnowbaseAsync extends EventEmitter {
|
|
35
|
+
/**
|
|
36
|
+
* @param {Object} [args]
|
|
37
|
+
* @param {string} [args.baseDir='./snowbase'] - The base directory for the database.
|
|
38
|
+
* @param {boolean} [args.encryption=false] - Enable encryption
|
|
39
|
+
* @param {string} [args.secret] - Encryption secret
|
|
40
|
+
* @param {boolean} [args.logging=false] - Enable logging
|
|
41
|
+
* @param {boolean} [args.backup=false] - Enable backup
|
|
42
|
+
*/
|
|
43
|
+
constructor(args = {}) {
|
|
44
|
+
super();
|
|
45
|
+
this.baseDir = args.baseDir ? resolve(args.baseDir) : resolve("./snowbase");
|
|
46
|
+
if (args.encryption === true) {
|
|
47
|
+
if (!args.secret)
|
|
48
|
+
this._error(
|
|
49
|
+
"[INVALID_SECRET] Encryption secret is required when encryption is enabled",
|
|
50
|
+
);
|
|
51
|
+
this.secret = args.secret;
|
|
52
|
+
this.encryption = true;
|
|
53
|
+
} else {
|
|
54
|
+
this.encryption = false;
|
|
55
|
+
}
|
|
56
|
+
if (args.logging == true) {
|
|
57
|
+
this.logsDir = join(this.baseDir, "logs");
|
|
58
|
+
this.logging = true;
|
|
59
|
+
} else this.logging = false;
|
|
60
|
+
|
|
61
|
+
if (args.backup == true) {
|
|
62
|
+
this.backupDir = join(this.baseDir, "backups");
|
|
63
|
+
this.backup = true;
|
|
64
|
+
} else this.backup = false;
|
|
65
|
+
|
|
66
|
+
this.path = join(this.baseDir, "snowbase.json"); //Default path for the database file
|
|
67
|
+
if (this.logsDir) this.logsPath = join(this.logsDir, "snowbase.log");
|
|
68
|
+
|
|
69
|
+
this.storage = new FileStorageAsync({
|
|
70
|
+
baseDir: this.baseDir,
|
|
71
|
+
encryption: this.encryption,
|
|
72
|
+
secret: this.secret,
|
|
73
|
+
backup: this.backup,
|
|
74
|
+
logging: this.logging,
|
|
75
|
+
error: this._error.bind(this),
|
|
76
|
+
emit: this.emit.bind(this),
|
|
77
|
+
log: this._log.bind(this),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {any} data
|
|
83
|
+
* @param {string} [secret]
|
|
84
|
+
* @returns {Promise<string>}
|
|
85
|
+
*/
|
|
86
|
+
async _encrypt(data, secret) {
|
|
87
|
+
return encrypt(data, secret ? secret : this.secret || "");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {string} raw
|
|
92
|
+
* @param {string} [secret]
|
|
93
|
+
* @returns {Promise<any>}
|
|
94
|
+
*/
|
|
95
|
+
async _decrypt(raw, secret) {
|
|
96
|
+
return decrypt(raw, secret ? secret : this.secret || "");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {any} err
|
|
101
|
+
* @returns {Promise<never>}
|
|
102
|
+
*/
|
|
103
|
+
async _error(err) {
|
|
104
|
+
const error = new Error(String(err));
|
|
105
|
+
if (this.listenerCount("error") > 0) this.emit("error", error);
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {string|Function|undefined} path - Path to parse
|
|
111
|
+
* @returns {Promise<string[]|any>} - Path parts or full data if path is undefined or function
|
|
112
|
+
*/
|
|
113
|
+
async _parsePath(path) {
|
|
114
|
+
return await parsePath(path, this._error.bind(this));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* @param {string|undefined|Function} path - Path to parse
|
|
118
|
+
* @returns {Promise<any>} - Target value
|
|
119
|
+
*/
|
|
120
|
+
async _getTarget(path) {
|
|
121
|
+
const parts = await this._parsePath(path);
|
|
122
|
+
let current = await this.storage.readAsync();
|
|
123
|
+
for (const part of parts) {
|
|
124
|
+
if (
|
|
125
|
+
current == null ||
|
|
126
|
+
typeof current !== "object" ||
|
|
127
|
+
!Object.hasOwn(current, part)
|
|
128
|
+
) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
current = current[part];
|
|
132
|
+
}
|
|
133
|
+
return current;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @param {Object} data - Data to get target from
|
|
138
|
+
* @param {string} path - Path to get target from
|
|
139
|
+
* @returns {Promise<*>} Target value
|
|
140
|
+
*/
|
|
141
|
+
async _getTargetFromData(data, path) {
|
|
142
|
+
return await getTargetFromData(data, path, this._error.bind(this));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {string} operation - Operation to log
|
|
147
|
+
* @param {Object} [details] - Details of the operation
|
|
148
|
+
* @returns {Promise<string>}
|
|
149
|
+
*/
|
|
150
|
+
async _log(operation, details) {
|
|
151
|
+
return await log(
|
|
152
|
+
operation,
|
|
153
|
+
details,
|
|
154
|
+
this.logsPath || "",
|
|
155
|
+
this.emit.bind(this),
|
|
156
|
+
this.logging,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* @param {string} [path]
|
|
161
|
+
* @returns {Promise<any>}
|
|
162
|
+
* @example
|
|
163
|
+
* await db.get("user/name"); // returns "Pedro"
|
|
164
|
+
*
|
|
165
|
+
* await db.get(); // returns the entire database
|
|
166
|
+
*/
|
|
167
|
+
async get(path) {
|
|
168
|
+
const data = await this.storage.readAsync();
|
|
169
|
+
if (path === undefined) {
|
|
170
|
+
this.emit("get", { path: undefined, data });
|
|
171
|
+
return data;
|
|
172
|
+
}
|
|
173
|
+
const value = await this._getTargetFromData(data, path);
|
|
174
|
+
this.emit("get", { path, value });
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @param {string} [path]
|
|
180
|
+
* @returns {Promise<boolean>}
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* await db.get("user")// { name: "Pedro" }
|
|
184
|
+
*
|
|
185
|
+
* await db.remove("user/name"); // returns true
|
|
186
|
+
*
|
|
187
|
+
* await db.remove("user/age"); // returns false
|
|
188
|
+
*/
|
|
189
|
+
async remove(path) {
|
|
190
|
+
if (path === undefined)
|
|
191
|
+
this._error("[INVALID_PATH] Invalid path to remove");
|
|
192
|
+
const parts = await this._parsePath(path);
|
|
193
|
+
const data = await this.storage.readAsync();
|
|
194
|
+
let current = data;
|
|
195
|
+
|
|
196
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
197
|
+
const key = parts[i];
|
|
198
|
+
if (
|
|
199
|
+
current == null ||
|
|
200
|
+
typeof current !== "object" ||
|
|
201
|
+
!Object.hasOwn(current, key)
|
|
202
|
+
) {
|
|
203
|
+
await this._log("remove", {
|
|
204
|
+
path,
|
|
205
|
+
value: undefined,
|
|
206
|
+
removed: false,
|
|
207
|
+
});
|
|
208
|
+
return false; //returns false if the path does not exist
|
|
209
|
+
}
|
|
210
|
+
current = current[key];
|
|
211
|
+
}
|
|
212
|
+
const lastKey = parts[parts.length - 1];
|
|
213
|
+
if (!Object.hasOwn(current, lastKey)) {
|
|
214
|
+
this._log("remove", { path, value: undefined, removed: false });
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
this._log("remove", { path, value: current[lastKey], removed: true });
|
|
218
|
+
delete current[lastKey];
|
|
219
|
+
await this.storage.writeAsync(data);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Clears the entire database
|
|
225
|
+
* @returns {Promise<boolean>} Returns `true` if the database was cleared
|
|
226
|
+
*
|
|
227
|
+
* @example
|
|
228
|
+
* await db.clear(); // returns true
|
|
229
|
+
*/
|
|
230
|
+
async clear() {
|
|
231
|
+
const data = await this.storage.readAsync();
|
|
232
|
+
await this.storage.writeAsync({});
|
|
233
|
+
await this._log("clear", { value: data });
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* @param {string} path
|
|
239
|
+
* @param {any} value
|
|
240
|
+
* @returns {Promise<void>}
|
|
241
|
+
*
|
|
242
|
+
* @example
|
|
243
|
+
* await db.set("user/name", "Pedro"); // set the value of user/name to "Pedro"
|
|
244
|
+
*/
|
|
245
|
+
async set(path, value) {
|
|
246
|
+
if (!path) this._error("[MISSING_KEY] Missing a key to set");
|
|
247
|
+
if (value === undefined)
|
|
248
|
+
this._error("[MISSING_VALUE] Missing value to set");
|
|
249
|
+
const parts = await this._parsePath(path);
|
|
250
|
+
const data = await this.storage.readAsync();
|
|
251
|
+
let current = data;
|
|
252
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
253
|
+
const key = parts[i];
|
|
254
|
+
if (current[key] == null || typeof current[key] !== "object") {
|
|
255
|
+
current[key] = {};
|
|
256
|
+
}
|
|
257
|
+
current = current[key];
|
|
258
|
+
}
|
|
259
|
+
current[parts[parts.length - 1]] = value;
|
|
260
|
+
await this.storage.writeAsync(data);
|
|
261
|
+
this._log("set", { path, key: parts[parts.length - 1], value });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* @param {string} path
|
|
266
|
+
* @param {any} value
|
|
267
|
+
* @returns {Promise<void>}
|
|
268
|
+
*
|
|
269
|
+
* @example
|
|
270
|
+
* await db.set("user/name", "Pedro"); // set the value of user/name to "Pedro"
|
|
271
|
+
*/
|
|
272
|
+
async save(path, value) {
|
|
273
|
+
await this.set(path, value);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Return all values from a specified path with a filter.
|
|
278
|
+
*
|
|
279
|
+
* If dont provide path, it will search in the entire database.
|
|
280
|
+
* It only search in the first level of the path (if you provide path='/users', it will only search in the users object, not in the nested objects).
|
|
281
|
+
*
|
|
282
|
+
* @param {string|((value: { key: string; value: any; }, index: number, array: { key: string; value: any; }[]) => any)} [path] - Path to get all values from or filter function if path is omitted
|
|
283
|
+
* @param {((value: { key: string; value: any; }, index: number, array: { key: string; value: any; }[]) => any)} [filter] - Filter to apply to the values
|
|
284
|
+
* @returns {Promise<{ key: string; value: any; }[]>} Array of key-value pairs
|
|
285
|
+
*
|
|
286
|
+
* @example
|
|
287
|
+
* await db.get("users")// { pedro: {name: "Pedro"}, laura: {name: "Laura"}, john: {name: "John"}}
|
|
288
|
+
* await db.get("animals")// {dog: {name: "Duke"}, cat: {name: "Princess"}}
|
|
289
|
+
*
|
|
290
|
+
* await db.all("users", (x) => x.key.includes("r")) // returns all values with key that includes "r" ( [ { key: "pedro", value: { ... } }, { key: "laura", value: { ... } } ] )
|
|
291
|
+
* await db.all((x) => x.key.includes("ani")) // returns all values with key that includes "ani" ( [ { key: "animals", value: { ... } } ] )
|
|
292
|
+
*/
|
|
293
|
+
async all(path, filter) {
|
|
294
|
+
if (typeof path === "function") {
|
|
295
|
+
filter = path;
|
|
296
|
+
path = undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (filter !== undefined && typeof filter !== "function") {
|
|
300
|
+
this._error("[INVALID_FILTER] Filter must be a function");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let target =
|
|
304
|
+
path !== undefined
|
|
305
|
+
? await this._getTarget(path)
|
|
306
|
+
: await this.storage.readAsync();
|
|
307
|
+
if (target == null || typeof target !== "object") return [];
|
|
308
|
+
|
|
309
|
+
const formatted = Object.entries(target).map(([key, value]) => ({
|
|
310
|
+
key,
|
|
311
|
+
value,
|
|
312
|
+
}));
|
|
313
|
+
return filter ? formatted.filter(filter) : formatted;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Checks if a path exists
|
|
318
|
+
* @param {string} path - Path to check
|
|
319
|
+
* @returns {Promise<boolean>} Returns `true` if the path exists, `false` otherwise
|
|
320
|
+
*
|
|
321
|
+
* @example
|
|
322
|
+
* await db.get("user")// { name: "Pedro" }
|
|
323
|
+
*
|
|
324
|
+
* await db.has("user/name"); // true
|
|
325
|
+
*
|
|
326
|
+
* await db.has("user/age"); // false
|
|
327
|
+
*/
|
|
328
|
+
async has(path) {
|
|
329
|
+
if (!path) this._error("[INVALID_PATH] Invalid path to check");
|
|
330
|
+
return (await this._getTarget(path)) !== undefined;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Return the number of values at a specified path
|
|
335
|
+
*
|
|
336
|
+
* If dont provide path, it will search in the entire database.
|
|
337
|
+
* It only search in the first level of the path (if you provide path='/users', it will only search in the users object, not in the nested objects).
|
|
338
|
+
*
|
|
339
|
+
* @param {string|((value: { key: string; value: any; }, index: number, array: { key: string; value: any; }[]) => any)} [path] - Path to count values from or filter function if path is omitted
|
|
340
|
+
* @param {(value: { key: string; value: any; }, index: number, array: { key: string; value: any; }[]) => any} [filter] - Filter to apply to the values
|
|
341
|
+
* @returns {Promise<number>} Number of values at the specified path
|
|
342
|
+
*
|
|
343
|
+
* @example
|
|
344
|
+
* await db.get("users")// { pedro: {name: "Pedro"}, laura: {name: "Laura"}, john: {name: "John"}}
|
|
345
|
+
*
|
|
346
|
+
* await db.count("users"); // 3
|
|
347
|
+
*
|
|
348
|
+
* await db.count("users", (x) => x.key.includes("r")); // 2
|
|
349
|
+
*/
|
|
350
|
+
async count(path, filter) {
|
|
351
|
+
if (path === undefined && filter === undefined)
|
|
352
|
+
return (await this.all()).length;
|
|
353
|
+
if (typeof path === "function") {
|
|
354
|
+
filter = path;
|
|
355
|
+
path = undefined;
|
|
356
|
+
}
|
|
357
|
+
//how many entries are in the database, with an optional filter function to count only specific entries
|
|
358
|
+
return (await this.all(path, filter)).length;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Return the first value that satisfies the predicate function
|
|
363
|
+
*
|
|
364
|
+
* If dont provide path, it will search in the entire database.
|
|
365
|
+
* It only search in the first level of the path (if you provide path='/users', it will only search in the users object, not in the nested objects).
|
|
366
|
+
*
|
|
367
|
+
* @param {string|((value: { key: string; value: any; }, index: number, array: { key: string; value: any; }[]) => any)} [path] - Path to find value in or predicate function if path is omitted
|
|
368
|
+
* @param {(value: { key: string; value: any; }, index: number, array: { key: string; value: any; }[]) => any} [predicate] - Predicate function to apply to the values
|
|
369
|
+
* @returns {Promise<{ key: string; value: any; }|null>} Value at the specified path
|
|
370
|
+
*
|
|
371
|
+
* @example
|
|
372
|
+
* await db.get("users")// { laura: {name: "Laura"}, john: {name: "John"}, pedro: {name: "Pedro"} }
|
|
373
|
+
*
|
|
374
|
+
* await db.find("users", (x) => x.key.includes("r")); // { key: "laura", value: { ... } }
|
|
375
|
+
*
|
|
376
|
+
* await db.find((x) => x.key.includes("u")); // { key: "user", value: { ... } }
|
|
377
|
+
*
|
|
378
|
+
* await db.find((x) => x.key.includes("z")) // null
|
|
379
|
+
*/
|
|
380
|
+
async find(path, predicate) {
|
|
381
|
+
if (path === undefined && predicate === undefined) {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
if (typeof path === "function") {
|
|
385
|
+
predicate = path;
|
|
386
|
+
path = undefined;
|
|
387
|
+
}
|
|
388
|
+
if (typeof predicate !== "function")
|
|
389
|
+
this._error("[INVALID_PREDICATE] Predicate must be a function");
|
|
390
|
+
const data =
|
|
391
|
+
path !== undefined
|
|
392
|
+
? await this._getTarget(path)
|
|
393
|
+
: await this.storage.readAsync();
|
|
394
|
+
if (data === undefined || typeof data !== "object") return null;
|
|
395
|
+
const entries = Object.entries(data).map(([key, value]) => ({
|
|
396
|
+
key,
|
|
397
|
+
value,
|
|
398
|
+
}));
|
|
399
|
+
|
|
400
|
+
return (predicate && entries.find(predicate)) || null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
randomBytes,
|
|
3
|
+
createCipheriv,
|
|
4
|
+
createDecipheriv,
|
|
5
|
+
scryptSync,
|
|
6
|
+
} from "crypto";
|
|
7
|
+
import { stableStringify } from "./utils.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} password
|
|
11
|
+
* @param {Buffer} salt
|
|
12
|
+
* @returns {Buffer}
|
|
13
|
+
*/
|
|
14
|
+
export function getKey(password, salt) {
|
|
15
|
+
return scryptSync(password, salt, 32);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {any} data
|
|
20
|
+
* @param {string} secret
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function encrypt(data, secret) {
|
|
24
|
+
const iv = randomBytes(12);
|
|
25
|
+
const salt = randomBytes(16);
|
|
26
|
+
const key = getKey(secret, salt);
|
|
27
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
28
|
+
|
|
29
|
+
const encrypted = Buffer.concat([
|
|
30
|
+
cipher.update(stableStringify(data), "utf8"),
|
|
31
|
+
cipher.final(),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const tag = cipher.getAuthTag();
|
|
35
|
+
|
|
36
|
+
return JSON.stringify({
|
|
37
|
+
salt: salt.toString("base64"),
|
|
38
|
+
iv: iv.toString("base64"),
|
|
39
|
+
tag: tag.toString("base64"),
|
|
40
|
+
data: encrypted.toString("base64"),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} raw
|
|
46
|
+
* @param {string} secret
|
|
47
|
+
* @returns {any}
|
|
48
|
+
*/
|
|
49
|
+
export function decrypt(raw, secret) {
|
|
50
|
+
const payload = JSON.parse(raw);
|
|
51
|
+
|
|
52
|
+
const salt = Buffer.from(payload.salt, "base64");
|
|
53
|
+
const key = getKey(secret, salt);
|
|
54
|
+
|
|
55
|
+
const decipher = createDecipheriv(
|
|
56
|
+
"aes-256-gcm",
|
|
57
|
+
key,
|
|
58
|
+
Buffer.from(payload.iv, "base64"),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
decipher.setAuthTag(Buffer.from(payload.tag, "base64"));
|
|
62
|
+
|
|
63
|
+
const decrypted = Buffer.concat([
|
|
64
|
+
decipher.update(Buffer.from(payload.data, "base64")),
|
|
65
|
+
decipher.final(),
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
return JSON.parse(decrypted.toString("utf8"));
|
|
69
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { decrypt, encrypt } from "./crypto.js";
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
import { readFileSync, writeFileSync, appendFileSync, copyFileSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} path
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function hash(path) {
|
|
11
|
+
const fileBuffer = readFileSync(path);
|
|
12
|
+
return createHash("sha256").update(fileBuffer).digest("hex");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Converts a value to a stable JSON string
|
|
17
|
+
* @param {any} value - Value to convert
|
|
18
|
+
* @returns {string} - Stable JSON string
|
|
19
|
+
*/
|
|
20
|
+
export function stableStringify(value) {
|
|
21
|
+
if (value === null || typeof value !== "object") {
|
|
22
|
+
return JSON.stringify(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
return `[${value.map((v) => stableStringify(v)).join(",")}]`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const keys = Object.keys(value).sort();
|
|
30
|
+
|
|
31
|
+
return `{${keys
|
|
32
|
+
.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
|
|
33
|
+
.join(",")}}`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Normalizes a chunk of data
|
|
37
|
+
* @param {string|Object} chunk - Chunk to normalize
|
|
38
|
+
* @returns {{raw: Object, stable: string}} - Normalized chunk
|
|
39
|
+
*/
|
|
40
|
+
export function normalize(chunk) {
|
|
41
|
+
const obj = typeof chunk === "string" ? JSON.parse(chunk) : chunk;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
raw: obj,
|
|
45
|
+
stable: stableStringify(obj),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Splits a path like "user/name/first" into ["user", "name", "first"] for nested access
|
|
50
|
+
* @param {string|undefined|Function} path - Path to parse
|
|
51
|
+
* @param {function} error - Error callback
|
|
52
|
+
* @returns {string[]} - Array of path parts
|
|
53
|
+
*/
|
|
54
|
+
export function parsePath(path, error) {
|
|
55
|
+
if (path == null || path === "") {
|
|
56
|
+
error("[INVALID_PATH] Invalid path");
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
if (typeof path !== "string") {
|
|
60
|
+
error("[INVALID_PATH] Path must be a string");
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
const parts = path.split("/").filter(Boolean);
|
|
64
|
+
const forbidden = new Set(["prototype", "__proto__", "constructor"]); //protecting against prototype pollution
|
|
65
|
+
if (parts.some((part) => forbidden.has(part)))
|
|
66
|
+
error("[FORBIDDEN_KEY] Path contains forbidden key");
|
|
67
|
+
if (parts.some((part) => part.includes(".")))
|
|
68
|
+
error("[INVALID_PATH] Path parts cannot contain dots");
|
|
69
|
+
if (parts.length === 0) error("[INVALID_PATH] Path is empty (just /)");
|
|
70
|
+
return parts;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Reads the database file
|
|
75
|
+
* @param {string} path - Database file path
|
|
76
|
+
* @param {function} error - Error callback
|
|
77
|
+
* @param {boolean} encryption - Is encryption enabled
|
|
78
|
+
* @param {string} [secret] - Encryption secret
|
|
79
|
+
* @returns {Record<string, any>} - Parsed database data
|
|
80
|
+
*/
|
|
81
|
+
export function read(path, error, encryption, secret) {
|
|
82
|
+
const raw = readFileSync(path, "utf8");
|
|
83
|
+
let data;
|
|
84
|
+
try {
|
|
85
|
+
data = encryption ? decrypt(raw, secret || "") : JSON.parse(raw);
|
|
86
|
+
} catch {
|
|
87
|
+
if (
|
|
88
|
+
!JSON.parse(raw).salt ||
|
|
89
|
+
!JSON.parse(raw).data ||
|
|
90
|
+
!JSON.parse(raw).tag ||
|
|
91
|
+
!JSON.parse(raw).iv
|
|
92
|
+
)
|
|
93
|
+
error(
|
|
94
|
+
"[INVALID_DATA] Database file is not encrypted or invalid JSON (data, salt, tag or iv not found)",
|
|
95
|
+
);
|
|
96
|
+
error(
|
|
97
|
+
"[INVALID_DATA] Database file contains invalid JSON or invalid encryption (perhaps invalid secret)",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (data == null || typeof data !== "object" || Array.isArray(data)) {
|
|
102
|
+
error("[INVALID_DATA] Database root must be an object");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
//validate dangerous keys
|
|
106
|
+
const forbidden = new Set(["prototype", "__proto__", "constructor"]);
|
|
107
|
+
/** @param {Record<string, any>} obj */
|
|
108
|
+
const hasForbiddenKey = (obj) => {
|
|
109
|
+
for (const key of Object.keys(obj)) {
|
|
110
|
+
if (forbidden.has(key)) {
|
|
111
|
+
error("[FORBIDDEN_KEY] Database contains forbidden key: " + key);
|
|
112
|
+
}
|
|
113
|
+
if (typeof obj[key] === "object" && obj[key] !== null) {
|
|
114
|
+
hasForbiddenKey(obj[key]);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
hasForbiddenKey(data);
|
|
120
|
+
return data;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Logs an operation
|
|
125
|
+
* @param {string} operation - Operation to log
|
|
126
|
+
* @param {Record<string, any>|undefined} details - Details of the operation
|
|
127
|
+
* @param {string|undefined} logsPath - Path to log file
|
|
128
|
+
* @param {function} emit - Emit event
|
|
129
|
+
* @param {boolean} logging - Enable logging
|
|
130
|
+
* @returns {string} - Timestamp
|
|
131
|
+
*/
|
|
132
|
+
export function log(operation, details, logsPath, emit, logging) {
|
|
133
|
+
let timestamp = new Date().toISOString();
|
|
134
|
+
if (!logging) {
|
|
135
|
+
emit(operation, details ? details : undefined, timestamp);
|
|
136
|
+
return timestamp;
|
|
137
|
+
}
|
|
138
|
+
if (!logsPath) {
|
|
139
|
+
emit(operation, details ? details : undefined, timestamp);
|
|
140
|
+
return timestamp;
|
|
141
|
+
}
|
|
142
|
+
if (!details)
|
|
143
|
+
appendFileSync(logsPath, `[${timestamp}] ${operation}\n`, "utf8");
|
|
144
|
+
if (operation) {
|
|
145
|
+
switch (operation) {
|
|
146
|
+
case "get":
|
|
147
|
+
appendFileSync(
|
|
148
|
+
logsPath,
|
|
149
|
+
`[${timestamp}] ${operation} - Path: ${details?.path || "/"}\n`,
|
|
150
|
+
"utf8",
|
|
151
|
+
);
|
|
152
|
+
break;
|
|
153
|
+
case "remove":
|
|
154
|
+
appendFileSync(
|
|
155
|
+
logsPath,
|
|
156
|
+
`[${timestamp}] ${operation} - Path: ${details?.path || "/"}\n`,
|
|
157
|
+
"utf8",
|
|
158
|
+
);
|
|
159
|
+
break;
|
|
160
|
+
case "set":
|
|
161
|
+
appendFileSync(
|
|
162
|
+
logsPath,
|
|
163
|
+
`[${timestamp}] ${operation} - Path: ${details?.path || "/"}\n`,
|
|
164
|
+
"utf8",
|
|
165
|
+
);
|
|
166
|
+
break;
|
|
167
|
+
case "clear":
|
|
168
|
+
appendFileSync(
|
|
169
|
+
logsPath,
|
|
170
|
+
`[${timestamp}] ${operation} - Cleared all data\n`,
|
|
171
|
+
"utf8",
|
|
172
|
+
);
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
emit(operation, details ? details : undefined, timestamp);
|
|
177
|
+
return timestamp;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Gets a target value from the data by path
|
|
182
|
+
* @param {Record<string, any>} data - Data to search in
|
|
183
|
+
* @param {string} path - Path to get target from
|
|
184
|
+
* @param {function} error - Error callback
|
|
185
|
+
* @returns {any} - Found value or undefined
|
|
186
|
+
*/
|
|
187
|
+
export function getTargetFromData(data, path, error) {
|
|
188
|
+
const parts = parsePath(path, error);
|
|
189
|
+
let current = data;
|
|
190
|
+
|
|
191
|
+
for (const part of parts) {
|
|
192
|
+
if (
|
|
193
|
+
current == null ||
|
|
194
|
+
typeof current !== "object" ||
|
|
195
|
+
!Object.hasOwn(current, part)
|
|
196
|
+
) {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
current = current[part];
|
|
200
|
+
}
|
|
201
|
+
return current;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Writes data to the database file
|
|
206
|
+
* @param {string} path - Database file path
|
|
207
|
+
* @param {Record<string, any>} data - Data to write
|
|
208
|
+
* @param {function} error - Error callback
|
|
209
|
+
* @param {function} emit - Emit event
|
|
210
|
+
* @param {boolean} encryption - Is encryption enabled
|
|
211
|
+
* @param {string|undefined} secret - Encryption secret
|
|
212
|
+
*/
|
|
213
|
+
export function write(path, data, error, emit, encryption, secret) {
|
|
214
|
+
let content = "";
|
|
215
|
+
try {
|
|
216
|
+
content = encryption ? encrypt(data, secret || "") : stableStringify(data);
|
|
217
|
+
} catch {
|
|
218
|
+
error("[INVALID_DATA] Data must be JSON serializable or encryptable");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
writeFileSync(path, content, "utf8");
|
|
222
|
+
emit("_write", data, new Date().toISOString());
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function randomId5() {
|
|
226
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
227
|
+
let id = "";
|
|
228
|
+
|
|
229
|
+
for (let i = 0; i < 5; i++) {
|
|
230
|
+
id += chars[Math.floor(Math.random() * chars.length)];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return id;
|
|
234
|
+
}
|