@63klabs/cache-data 1.2.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.
@@ -0,0 +1,2024 @@
1
+ /*
2
+ * =============================================================================
3
+ * Classes for caching application data. Uses S3 and DynamoDb.
4
+ * -----------------------------------------------------------------------------
5
+ *
6
+ *
7
+ */
8
+
9
+ /*
10
+ * -----------------------------------------------------------------------------
11
+ * Object definitions
12
+ * -----------------------------------------------------------------------------
13
+ */
14
+
15
+ /**
16
+ * @typedef CacheDataFormat
17
+ * @property {Object} cache
18
+ * @property {string} cache.body
19
+ * @property {Object} cache.headers
20
+ * @property {number} cache.expires
21
+ * @property {string} cache.statusCode
22
+ */
23
+
24
+ /*
25
+ * -----------------------------------------------------------------------------
26
+ */
27
+
28
+ const tools = require("./tools/index.js");
29
+
30
+ /* for hashing and encrypting */
31
+ const crypto = require("crypto"); // included by aws so don't need to add to package.json
32
+ const objHash = require('object-hash');
33
+ const moment = require('moment-timezone');
34
+
35
+ /**
36
+ * Basic S3 read/write for cache data. No cache logic,
37
+ * only handles the storage format and retrieval.
38
+ * Logic is handled by CacheData.
39
+ */
40
+ class S3Cache {
41
+
42
+ static #bucket = null;
43
+ static #objPath = "cache/";
44
+
45
+ constructor () {
46
+ };
47
+
48
+ /**
49
+ *
50
+ * @returns {string} The bucket name used for cached data
51
+ */
52
+ static getBucket() {
53
+ return this.#bucket;
54
+ };
55
+
56
+ /**
57
+ *
58
+ * @returns {string} The object key (path) for cache objects
59
+ */
60
+ static getPath() {
61
+ return this.#objPath;
62
+ };
63
+
64
+ /**
65
+ * Initialize the S3 bucket for storing cached data.
66
+ * @param {string} bucket The bucket name for storing cached data
67
+ */
68
+ static init(bucket) {
69
+ if ( S3Cache.getBucket() === null) {
70
+ this.#bucket = bucket;
71
+ } else {
72
+ tools.DebugAndLog.error("S3Cache already initialized. Ignoring call to S3Cache.init()");
73
+ }
74
+ };
75
+
76
+ /**
77
+ * S3Cache information
78
+ * @returns {{bucket: string, path: string}} The bucket and path (key) used for cached data
79
+ */
80
+ static info() {
81
+ return {
82
+ bucket: S3Cache.getBucket(),
83
+ path: S3Cache.getPath()
84
+ };
85
+ };
86
+
87
+ /**
88
+ * @param {Buffer|ReadableStream} s3Body
89
+ * @returns {object} a parsed JSON object
90
+ */
91
+ static async s3BodyToObject(s3Body) {
92
+ let str = "";
93
+ let obj = {};
94
+
95
+ // check if s3Body is buffer or stream
96
+ if (s3Body instanceof Buffer) {
97
+ str = s3Body.toString('utf-8');
98
+ } else {
99
+ str = await s3Body.transformToString();
100
+ }
101
+
102
+ obj = JSON.parse(str); // TODO: if it is a stream, there are better JSON parse options
103
+
104
+ return obj;
105
+ }
106
+
107
+ /**
108
+ * Read cache data from S3 for given idHash
109
+ * @param {string} idHash The id of the cached content to retrieve
110
+ * @returns {Promise<object>} Cache data
111
+ */
112
+ static async read (idHash) {
113
+
114
+ return new Promise(async (resolve, reject) => {
115
+
116
+ const objKey = `${S3Cache.getPath()}${idHash}.json`;
117
+ const objFullLocation = `${S3Cache.getBucket()}/${objKey}`;
118
+ tools.DebugAndLog.debug(`Getting object from S3: ${objFullLocation}`);
119
+
120
+ let item = null;
121
+
122
+ try {
123
+
124
+ let params = {
125
+ Bucket: S3Cache.getBucket(),
126
+ Key: objKey,
127
+ ResponseContentType:'application/json'
128
+ };
129
+
130
+ const result = await tools.AWS.s3.get(params);
131
+
132
+ tools.DebugAndLog.debug(`Success getting object from S3 ${objFullLocation}`);
133
+
134
+ item = await S3Cache.s3BodyToObject(result.Body);
135
+
136
+ tools.DebugAndLog.debug(`Success parsing object from S3 ${objFullLocation}`);
137
+
138
+ resolve(item);
139
+
140
+ } catch (error) {
141
+ tools.DebugAndLog.error(`Error getting object from S3 (${objFullLocation}): ${error.message}`, error.stack);
142
+ reject(item);
143
+ }
144
+
145
+ });
146
+
147
+ };
148
+
149
+ /**
150
+ * Write data to cache in S3
151
+ * @param {string} idHash ID of data to write
152
+ * @param {Object} data Data to write to cache
153
+ * @returns {Promise<boolean>} Whether or not the write was successful
154
+ */
155
+ static async write (idHash, data) {
156
+
157
+ const objKey = `${S3Cache.getPath()}${idHash}.json`;
158
+ const objFullLocation = `${S3Cache.getBucket()}/${objKey}`;
159
+ tools.DebugAndLog.debug(`Putting object to S3: ${objFullLocation}`);
160
+
161
+ return new Promise( async (resolve, reject) => {
162
+
163
+ try {
164
+ const params = {
165
+ Bucket: S3Cache.getBucket(),
166
+ Key: objKey,
167
+ Body: data,
168
+ ContentType: 'application/json'
169
+ };
170
+
171
+ let response = await tools.AWS.s3.put(params);
172
+
173
+ tools.DebugAndLog.debug(`Put object to S3 ${objFullLocation}`, response);
174
+
175
+ resolve(true);
176
+
177
+ } catch (error) {
178
+ tools.DebugAndLog.error(`Error putting object to S3. [E2] (${objFullLocation}) ${error.message}`, error.stack);
179
+ reject(false)
180
+ };
181
+ });
182
+
183
+ };
184
+ };
185
+
186
+ /**
187
+ * Basic DynamoDb read/write for cache data. No cache logic,
188
+ * only handles the storage format and retrieval.
189
+ * Logic is handled by CacheData.
190
+ */
191
+ class DynamoDbCache {
192
+
193
+ static #table = null;
194
+
195
+ constructor () {
196
+ };
197
+
198
+ /**
199
+ * Initialize the DynamoDb settings for storing cached data
200
+ * @param {string} table The table name to store cached data
201
+ */
202
+ static init(table) {
203
+ if ( this.#table === null ) {
204
+ this.#table = table;
205
+ } else {
206
+ tools.DebugAndLog.error("DynamoDbCache already initialized. Ignoring call to DynamoDbCache.init()");
207
+ }
208
+ };
209
+
210
+ /**
211
+ * Information about the DynamoDb table storing cached data
212
+ * @returns {string} The DynamoDb table name
213
+ */
214
+ static info() {
215
+ return this.#table;
216
+ };
217
+
218
+ /**
219
+ * Read cache data from DynamoDb for given idHash
220
+ * @param {string} idHash The id of the cached content to retrieve
221
+ * @returns {Promise<object>} Cached data
222
+ */
223
+ static async read (idHash) {
224
+
225
+ return new Promise(async (resolve, reject) => {
226
+
227
+ tools.DebugAndLog.debug(`Getting record from DynamoDb for id_hash: ${idHash}`)
228
+ let result = {};
229
+
230
+ // https://www.fernandomc.com/posts/eight-examples-of-fetching-data-from-dynamodb-with-node/
231
+ try {
232
+ let params = {
233
+ TableName: this.#table,
234
+ Key: {
235
+ "id_hash": idHash
236
+ },
237
+ ExpressionAttributeNames: {
238
+ "#expires": "expires",
239
+ "#data": "data"
240
+ },
241
+ ProjectionExpression: "id_hash, #data, #expires"
242
+ };
243
+
244
+ result = await tools.AWS.dynamo.get(params);
245
+
246
+ tools.DebugAndLog.debug(`Query success from DynamoDb for id_hash: ${idHash}`);
247
+
248
+ resolve(result);
249
+ } catch (error) {
250
+ tools.DebugAndLog.error(`Unable to perform DynamoDb query. (${idHash}) ${error.message}`, error.stack);
251
+ reject(result);
252
+ };
253
+
254
+ });
255
+
256
+ };
257
+
258
+ /**
259
+ * Write data to cache in DynamoDb
260
+ * @param {object} item JSON object to write to DynamoDb
261
+ * @returns {Promise<boolean>} Whether or not the write was successful
262
+ */
263
+ static async write (item) {
264
+
265
+ return new Promise( async (resolve, reject) => {
266
+
267
+ try {
268
+
269
+ tools.DebugAndLog.debug(`Putting record to DynamoDb for id_hash: ${item.id_hash}`)
270
+
271
+ let params = {
272
+ Item: item,
273
+ TableName: this.#table
274
+ };
275
+
276
+ let response = await tools.AWS.dynamo.put(params);
277
+
278
+ tools.DebugAndLog.debug(`Write to DynamoDb for id_hash: ${item.id_hash}`, response);
279
+
280
+ resolve(true);
281
+
282
+ } catch (error) {
283
+ tools.DebugAndLog.error(`Write to DynamoDb failed for id_hash: ${item.id_hash} ${error.message}`, error.stack);
284
+ reject(false)
285
+ };
286
+ });
287
+
288
+ };
289
+
290
+ };
291
+
292
+ /**
293
+ * Accesses cached data stored in DynamoDb and S3. CacheData is a static
294
+ * object that manages expiration calculations, accessing and storing data.
295
+ * This class is used by the publicly exposed class Cache
296
+ */
297
+ class CacheData {
298
+
299
+ static PRIVATE = "private";
300
+ static PUBLIC = "public";
301
+
302
+ static PLAIN_ENCODING = "utf8";
303
+ static CRYPT_ENCODING = "hex";
304
+
305
+ static #secureDataAlgorithm = null;
306
+ static #secureDataKey = null;
307
+ static #dynamoDbMaxCacheSize_kb = 10;
308
+ static #purgeExpiredCacheEntriesAfterXHours = 24;
309
+ static #timeZoneForInterval = "UTC";
310
+ static #offsetInMinutes = 0;
311
+
312
+ constructor() {
313
+ };
314
+
315
+ /**
316
+ *
317
+ * @param {Object} parameters
318
+ * @param {string} parameters.dynamoDbTable
319
+ * @param {string} parameters.s3Bucket
320
+ * @param {string} parameters.secureDataAlgorithm
321
+ * @param {string|Buffer|tools.Secret|tools.CachedSSMParameter|tools.CachedSecret} parameters.secureDataKey
322
+ * @param {number} parameters.DynamoDbMaxCacheSize_kb
323
+ * @param {number} parameters.purgeExpiredCacheEntriesAfterXHours
324
+ * @param {string} parameters.timeZoneForInterval
325
+ */
326
+ static init(parameters) {
327
+
328
+ // if we don't have the key set, we don't have anything set
329
+ if ( this.#secureDataKey === null ) {
330
+
331
+ // TODO: Throw error if data is missing
332
+
333
+ DynamoDbCache.init(parameters.dynamoDbTable);
334
+ S3Cache.init(parameters.s3Bucket);
335
+
336
+ // set other values
337
+ this.#secureDataAlgorithm = parameters.secureDataAlgorithm;
338
+ this.#secureDataKey = parameters.secureDataKey;
339
+
340
+ if ("DynamoDbMaxCacheSize_kb" in parameters ) { this.#dynamoDbMaxCacheSize_kb = parameters.DynamoDbMaxCacheSize_kb; }
341
+ if ("purgeExpiredCacheEntriesAfterXHours" in parameters ) { this.#purgeExpiredCacheEntriesAfterXHours = parameters.purgeExpiredCacheEntriesAfterXHours; }
342
+ if ("timeZoneForInterval" in parameters ) { this.#timeZoneForInterval = parameters.timeZoneForInterval; }
343
+
344
+ this._setOffsetInMinutes();
345
+
346
+ } else {
347
+ tools.DebugAndLog.warn("CacheData already initialized. Ignoring call to CacheData.init()");
348
+ }
349
+
350
+ };
351
+
352
+ /**
353
+ * Similar to init, but runs during execution time to refresh environment variables that may have changed since init.
354
+ * Calling .prime() without an await can help get runtime refreshes started.
355
+ * You can safely call .prime() again with an await to make sure it has completed just before you need it.
356
+ * @returns {Promise<boolean>}
357
+ */
358
+ static async prime() {
359
+ return new Promise(async (resolve, reject) => {
360
+ try {
361
+ let primeTasks = [];
362
+
363
+ if (CacheData.getSecureDataKeyType() === 'CachedParameterSecret') {
364
+ primeTasks.push( this.#secureDataKey.prime());
365
+ }
366
+
367
+ await Promise.all(primeTasks);
368
+
369
+ resolve(true);
370
+ } catch (error) {
371
+ tools.DebugAndLog.error(`CacheData.prime() failed ${error.message}`, error.stack);
372
+ reject(false);
373
+ }
374
+ });
375
+ }
376
+
377
+ /**
378
+ * Used in the init() method, based on the timeZoneForInterval and current
379
+ * date, set the offset in minutes (offset from UTC taking into account
380
+ * daylight savings time for that time zone)
381
+ */
382
+ static _setOffsetInMinutes() {
383
+ this.#offsetInMinutes = ( -1 * moment.tz.zone(this.getTimeZoneForInterval()).utcOffset(Date.now())); // invert by *-1 because of POSIX
384
+ };
385
+
386
+ /**
387
+ *
388
+ * @returns {number} The offset in minutes taking into account whether or not daylight savings is in effect AND observed
389
+ */
390
+ static getOffsetInMinutes() {
391
+ return this.#offsetInMinutes;
392
+ };
393
+
394
+ /**
395
+ * https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
396
+ * @returns {string} Returns the TZ database name assigned to the time zone
397
+ */
398
+ static getTimeZoneForInterval() {
399
+ return this.#timeZoneForInterval;
400
+ };
401
+
402
+ /**
403
+ * Get information about the cache settings
404
+ * @returns {{
405
+ * dynamoDbTable: string,
406
+ * s3Bucket: string,
407
+ * secureDataAlgorithm: string,
408
+ * secureDataKey: string,
409
+ * DynamoDbMaxCacheSize_kb: number,
410
+ * purgeExpiredCacheEntriesAfterXHours: number,
411
+ * timeZoneForInterval: string,
412
+ * offsetInMinutes: number
413
+ * }}
414
+ */
415
+ static info() {
416
+
417
+ return {
418
+ dynamoDbTable: DynamoDbCache.info(),
419
+ s3Bucket: S3Cache.info(),
420
+ secureDataAlgorithm: this.#secureDataAlgorithm,
421
+ secureDataKey: `************** [${CacheData.getSecureDataKeyType()}]`,
422
+ DynamoDbMaxCacheSize_kb: this.#dynamoDbMaxCacheSize_kb,
423
+ purgeExpiredCacheEntriesAfterXHours: this.#purgeExpiredCacheEntriesAfterXHours,
424
+ timeZoneForInterval: CacheData.getTimeZoneForInterval(),
425
+ offsetInMinutes: CacheData.getOffsetInMinutes()
426
+ };
427
+
428
+ };
429
+
430
+ /**
431
+ * Format the cache object for returning to main program
432
+ * @param {number} expires
433
+ * @param {Object} body
434
+ * @param {Object} headers
435
+ * @param {string} statusCode
436
+ * @returns {CacheDataFormat} Formatted cache object
437
+ */
438
+ static format(expires = null, body = null, headers = null, statusCode = null) {
439
+ return { "cache": { body: body, headers: headers, expires: expires, statusCode: statusCode } };
440
+ };
441
+
442
+ /**
443
+ *
444
+ * @param {string} idHash
445
+ * @param {Object} item
446
+ * @param {number} syncedNow
447
+ * @param {number} syncedLater
448
+ * @returns {Promise<{ body: string, headers: Object, expires: number, statusCode: string }>}
449
+ */
450
+ static async _process(idHash, item, syncedNow, syncedLater) {
451
+
452
+ return new Promise(async (resolve, reject) => {
453
+
454
+ try {
455
+
456
+ // Is this a pointer to data in S3?
457
+ if ("data" in item && "info" in item.data && "objInS3" in item.data.info && item.data.info.objInS3 === true) {
458
+ tools.DebugAndLog.debug(`Item is in S3. Fetching... (${idHash})`);
459
+ item = await S3Cache.read(idHash); // The data is stored in S3 so get it
460
+ tools.DebugAndLog.debug(`Item returned from S3 replaces pointer to S3 (${idHash})`, item);
461
+ // NOTE: if this fails and returns null it will be handled as any item === null which is to say that body will be null
462
+ }
463
+
464
+ let body = null;
465
+ let headers = null;
466
+ let expires = syncedLater;
467
+ let statusCode = null;
468
+
469
+ if (item !== null) {
470
+ tools.DebugAndLog.debug(`Process data from cache (${idHash})`);
471
+ body = item.data.body; // set the cached body data (this is what we will be the body of the response)
472
+
473
+ headers = item.data.headers;
474
+ expires = item.expires;
475
+ statusCode = item.data.statusCode;
476
+
477
+ // if the body is encrypted (because classification is private) decrypt it
478
+ if ( item.data.info.classification === CacheData.PRIVATE ) {
479
+ try {
480
+ tools.DebugAndLog.debug(`Policy for (${idHash}) data is classified as PRIVATE. Decrypting body...`);
481
+ await CacheData.prime();
482
+ body = this._decrypt(body);
483
+ } catch (error) {
484
+ // Decryption failed
485
+ body = null;
486
+ expires = syncedNow;
487
+ headers = null;
488
+ statusCode = "500";
489
+ tools.DebugAndLog.error(`Unable to decrypt cache. Ignoring it. (${idHash}) ${error.message}`, error.stack);
490
+ }
491
+ }
492
+ }
493
+
494
+ resolve({ body: body, headers: headers, expires: expires, statusCode: statusCode });
495
+ } catch (error) {
496
+ tools.DebugAndLog.error(`Error getting data from cache. (${idHash}) ${error.message}`, error.stack);
497
+ reject( {body: null, expires: syncedNow, headers: null, statusCode: "500"} );
498
+ }
499
+
500
+ });
501
+ };
502
+
503
+ /**
504
+ *
505
+ * @param {string} idHash
506
+ * @param {number} syncedLater
507
+ * @returns {Promise<CacheDataFormat>}
508
+ */
509
+ static async read(idHash, syncedLater) {
510
+
511
+ return new Promise(async (resolve, reject) => {
512
+
513
+ let cache = this.format(syncedLater);
514
+
515
+ try {
516
+
517
+ const result = await DynamoDbCache.read(idHash);
518
+
519
+ /* if we have a cached object, provide it for evaluation */
520
+ /* NOTE: AWS-SDK seems to provide a hidden Item that is undefined? toString and stringify doesn't show it,
521
+ but "Item" in result will be true. So we will do extensive testing to make compatible with both v2 and v3 */
522
+ if ( "Item" in result && typeof result.Item !== "undefined" && result.Item !== null ) {
523
+ // hand the item over for processing
524
+ const cachedCopy = await this._process(idHash, result.Item);
525
+ cache = this.format(cachedCopy.expires, cachedCopy.body, cachedCopy.headers, cachedCopy.statusCode);
526
+ tools.DebugAndLog.debug(`Cached Item Processed: ${idHash}`);
527
+ } else {
528
+ tools.DebugAndLog.debug(`No cache found for ${idHash}`);
529
+ }
530
+
531
+ resolve(cache);
532
+ } catch (error) {
533
+ tools.DebugAndLog.error(`CacheData.read(${idHash}) failed ${error.message}`, error.stack);
534
+ reject(cache);
535
+ };
536
+
537
+ });
538
+ };
539
+
540
+ /**
541
+ *
542
+ * @param {string} idHash ID of data to write
543
+ * @param {number} syncedNow
544
+ * @param {string} body
545
+ * @param {Object} headers
546
+ * @param {string} host
547
+ * @param {string} path
548
+ * @param {number} expires
549
+ * @param {number} statusCode
550
+ * @param {boolean} encrypt
551
+ * @returns {CacheDataFormat}
552
+ */
553
+ static write (idHash, syncedNow, body, headers, host, path, expires, statusCode, encrypt = true) {
554
+
555
+ let cacheData = null;
556
+
557
+ try {
558
+
559
+ tools.DebugAndLog.debug(`Updating Cache for ${idHash} now:${syncedNow} | host:${host} | path:${path} | expires:${expires} | statusCode:${statusCode} | encrypt:${encrypt} ... `);
560
+
561
+ if( isNaN(expires) || expires < syncedNow ) {
562
+ expires = syncedNow + 300;
563
+ }
564
+
565
+ // lowercase all headers
566
+ headers = CacheData.lowerCaseKeys(headers);
567
+
568
+ // set etag
569
+ if ( !("etag" in headers) ) {
570
+ headers.etag = CacheData.generateEtag(idHash, body);
571
+ }
572
+
573
+ // set last modified
574
+ if ( !("last-modified" in headers) ) {
575
+ headers['last-modified'] = CacheData.generateInternetFormattedDate(syncedNow);
576
+ }
577
+
578
+ // set expires in header
579
+ if ( !("expires" in headers) ) {
580
+ headers['expires'] = CacheData.generateInternetFormattedDate(expires);
581
+ }
582
+
583
+ cacheData = CacheData.format(expires, body, headers, statusCode);
584
+
585
+ const bodySize_kb = this.calculateKBytes(body);
586
+ let bodyToStore = body;
587
+
588
+ // if the endpoint policy is classified as private, encrypt
589
+ if ( encrypt ) {
590
+ tools.DebugAndLog.debug(`Policy for (${idHash}) data is classified as PRIVATE. Encrypting body...`);
591
+ bodyToStore = this._encrypt(body);
592
+ }
593
+
594
+ // create the (preliminary) cache record
595
+ let item = {
596
+ id_hash: idHash,
597
+ expires: expires,
598
+ purge_ts: (syncedNow + (this.#purgeExpiredCacheEntriesAfterXHours * 3600)),
599
+ data: {
600
+ info: {
601
+ expires: headers.expires,
602
+ host: host,
603
+ path: path,
604
+ classification: (encrypt ? CacheData.PRIVATE : CacheData.PUBLIC),
605
+ size_kb: bodySize_kb,
606
+ objInS3: false
607
+ },
608
+ headers: headers,
609
+ body: bodyToStore,
610
+ statusCode: statusCode
611
+ }
612
+ };
613
+
614
+ /*
615
+ DynamoDb has a limit of 400KB per item so we want to make sure
616
+ the Item does not take up that much space. Also, we want
617
+ DynamoDb to run efficiently so it is best to only store smaller
618
+ items there and move larger items into S3.
619
+
620
+ Any items larger than the max size we set will be stored over
621
+ in S3.
622
+
623
+ What is the max size? It can be set in the Lambda Environment
624
+ Variables and discovering the proper balance will take some trials.
625
+ We don't want to constantly be calling S3, but we also don't want
626
+ to make DynamoDb too heavy either.
627
+
628
+ (In summary: Max Item size in DynamoDb is 400KB, and storing too many large
629
+ items can have a performance impact. However constantly calling
630
+ S3 also will have a performance impact.)
631
+ */
632
+
633
+ // do the size check
634
+ if (bodySize_kb > this.#dynamoDbMaxCacheSize_kb) {
635
+ // over max size limit set in Lambda Environment Variables
636
+ S3Cache.write(idHash, JSON.stringify(item) );
637
+ // update the Item we will pass to DynamoDb
638
+ let preview = (typeof item.data.body === "string") ? item.data.body.slice(0,100)+"..." : "[---ENCRYPTED---]";
639
+ item.data.body = "ID: "+idHash+" PREVIEW: "+preview;
640
+ item.data.info.objInS3 = true;
641
+ }
642
+
643
+ DynamoDbCache.write(item); // we don't wait for a response
644
+
645
+ } catch (error) {
646
+ tools.DebugAndLog.error(`CacheData.write for ${idHash} FAILED now:${syncedNow} | host:${host} | path:${path} | expires:${expires} | statusCode:${statusCode} | encrypt:${encrypt} failed. ${error.message}`, error.stack);
647
+ cacheData = CacheData.format(0);
648
+ };
649
+
650
+ return cacheData;
651
+
652
+ };
653
+
654
+ /*
655
+ ***********************************************************************
656
+ Encryption Functions
657
+ -----------------------------------------------------------------------
658
+
659
+ Encrypt and Decrypt data classified as private
660
+
661
+ Even though we can set up DynamoDB to encrypt data when at rest, we
662
+ don't want data classified as private to be viewed or exported from the
663
+ AWS console
664
+
665
+ -----------------------------------------------------------------------
666
+
667
+ Adapted from
668
+ https://codeforgeek.com/encrypt-and-decrypt-data-in-node-js/
669
+
670
+ and
671
+ https://nodejs.org/api/crypto.html
672
+
673
+ ************************************************************************
674
+ */
675
+
676
+ /**
677
+ * Returns the type of secureDataKey
678
+ *
679
+ * @returns {string} 'buffer', 'string', 'CachedParameterSecret'
680
+ */
681
+ static getSecureDataKeyType() {
682
+ // look at type of parameters.secureDataKey as it can be a string, Buffer, or object.
683
+ let dataKeyType = typeof this.#secureDataKey;
684
+ if ( Buffer.isBuffer(this.#secureDataKey)) { dataKeyType = 'buffer'; }
685
+ if ( dataKeyType === 'object' && this.#secureDataKey instanceof tools.CachedParameterSecret ) { dataKeyType = 'CachedParameterSecret'; }
686
+ return dataKeyType;
687
+ }
688
+
689
+ /**
690
+ * Obtain the secureDataKey as a Buffer for encryption/decryption.
691
+ *
692
+ * @returns {Buffer|null} The Data key as a buffer in the format specified by CacheData.CRYPT_ENCODING
693
+ */
694
+ static getSecureDataKey() {
695
+
696
+ let buff = null;
697
+
698
+ try {
699
+
700
+ // The secureDataKey can be stored several different ways
701
+ switch (CacheData.getSecureDataKeyType()) {
702
+ case 'buffer':
703
+ buff = this.#secureDataKey;
704
+ break;
705
+ case 'string':
706
+ buff = Buffer.from(this.#secureDataKey, this.CRYPT_ENCODING);
707
+ break;
708
+ case 'CachedParameterSecret':
709
+ // it may be null
710
+ const key = this.#secureDataKey.sync_getValue();
711
+ buff = ( key === null ) ? null : Buffer.from( key, this.CRYPT_ENCODING);
712
+ break;
713
+ default:
714
+ break;
715
+ }
716
+
717
+ } catch (error) {
718
+ tools.DebugAndLog.error(`CacheData.getSecureDataKey() failed ${error.message}`, error.stack);
719
+ }
720
+
721
+ return buff;
722
+
723
+ };
724
+
725
+ /**
726
+ *
727
+ * @param {string} text Data to encrypt
728
+ * @returns {string} Encrypted data
729
+ */
730
+ static _encrypt (text) {
731
+
732
+ const dataKey = this.getSecureDataKey();
733
+
734
+ tools.DebugAndLog.debug(`Encrypting cache using ${this.#secureDataAlgorithm} with secureDataKey [${CacheData.getSecureDataKeyType()}] ... `);
735
+
736
+ // can't encrypt null, so we'll substitute (and in _decrypt() reverse the sub)
737
+ if (text === null) { text = "{{{null}}}"; }
738
+
739
+ let iv = crypto.randomBytes(16);
740
+ let cipher = crypto.createCipheriv(this.#secureDataAlgorithm, Buffer.from(dataKey), iv);
741
+
742
+ let encrypted = cipher.update(text, this.PLAIN_ENCODING, this.CRYPT_ENCODING);
743
+ encrypted += cipher.final(this.CRYPT_ENCODING);
744
+
745
+ return { iv: iv.toString(this.CRYPT_ENCODING), encryptedData: encrypted };
746
+ };
747
+
748
+ /**
749
+ *
750
+ * @param {string} data Data to decrypt
751
+ * @returns {string} Decrypted data
752
+ */
753
+ static _decrypt (data) {
754
+
755
+ const dataKey = this.getSecureDataKey();
756
+
757
+ tools.DebugAndLog.debug(`Decrypting cache using ${this.#secureDataAlgorithm} with secureDataKey [${CacheData.getSecureDataKeyType()}] ... `);
758
+
759
+ let plainEncoding = ("plainEncoding" in data) ? data.plainEncoding : this.PLAIN_ENCODING;
760
+ let cryptEncoding = ("cryptEncoding" in data) ? data.cryptEncoding : this.CRYPT_ENCODING;
761
+
762
+ let iv = Buffer.from(data.iv, cryptEncoding);
763
+ let decipher = crypto.createDecipheriv(this.#secureDataAlgorithm, Buffer.from(dataKey), iv);
764
+
765
+ let decrypted = decipher.update(data.encryptedData, cryptEncoding, plainEncoding);
766
+ decrypted += decipher.final(plainEncoding);
767
+
768
+ // reverse the substitute for null that _encrypt() used
769
+ if ( decrypted === "{{{null}}}") { decrypted = null; }
770
+
771
+ return decrypted;
772
+ };
773
+
774
+ // utility functions
775
+ /**
776
+ * Generate an eTag hash
777
+ *
778
+ * This is very basic as there is no specification for doing this.
779
+ * All an eTag needs to be is a unique hash for a particular request.
780
+ * We already have a unique ID for the request, so it's not like we
781
+ * need to make sure the content matches (or does not match) any content
782
+ * throughout the rest of the world. We are just doing a quick check
783
+ * at this exact endpoint. So we pair the idHash (this exact endpoint)
784
+ * with it's content.
785
+ * @param {string} idHash The id of the endpoint
786
+ * @param {string} content The string to generate an eTag for
787
+ * @returns {string} 10 character ETag hash
788
+ */
789
+ static generateEtag (idHash, content) {
790
+ const hasher = crypto.createHash('sha1');
791
+ hasher.update(idHash+content);
792
+ return hasher.digest('hex').slice(0, 10); // we'll only take 10 characters
793
+ // again, we aren't comparing the hash to the rest of the world
794
+ };
795
+
796
+ /**
797
+ * Generate an internet formatted date such as those used in headers.
798
+ *
799
+ * Example: "Wed, 28 Jul 2021 12:24:11 GMT"
800
+ *
801
+ * The timestamp passed is expected to be in seconds. If it is in
802
+ * milliseconds then inMilliSeconds parameter needs to be set to
803
+ * true.
804
+ * @param {number} timestamp If in milliseconds, inMilliseconds parameter MUST be set to true
805
+ * @param {boolean} inMilliSeconds Set to true if timestamp passed is in milliseconds. Default is false
806
+ * @returns {string} An internet formatted date such as Wed, 28 Jul 2021 12:24:11 GMT
807
+ */
808
+ static generateInternetFormattedDate (timestamp, inMilliSeconds = false) {
809
+
810
+ if ( !inMilliSeconds ) {
811
+ timestamp = CacheData.convertTimestampFromSecondsToMilli(timestamp);
812
+ }
813
+
814
+ return new Date(timestamp).toUTCString();
815
+ };
816
+
817
+ /**
818
+ * Returns an object with lowercase keys. Note that if after
819
+ * lowercasing the keys there is a collision one will be
820
+ * over-written.
821
+ * Can be used for headers, response, or more.
822
+ * @param {Object} objectWithKeys
823
+ * @returns {Object} Same object but with lowercase keys
824
+ */
825
+ static lowerCaseKeys (objectWithKeys) {
826
+ let objectWithLowerCaseKeys = {};
827
+ if ( objectWithKeys !== null ) {
828
+ let keys = Object.keys(objectWithKeys);
829
+ // move each value from objectWithKeys to objectWithLowerCaseKeys
830
+ keys.forEach( function( k ) {
831
+ objectWithLowerCaseKeys[k.toLowerCase()] = objectWithKeys[k];
832
+ });
833
+ }
834
+ return objectWithLowerCaseKeys;
835
+ }
836
+
837
+ /**
838
+ * Calculate the number of Kilobytes in memory a String takes up.
839
+ * This function first calculates the number of bytes in the String using
840
+ * Buffer.byteLength() and then converts it to KB = (bytes / 1024)
841
+ * @param {string} aString A string to calculate on
842
+ * @param {string} encode What character encoding should be used? Default is "utf8"
843
+ * @returns {number} String size in estimated KB
844
+ */
845
+ static calculateKBytes ( aString, encode = CacheData.PLAIN_ENCODING ) {
846
+ let kbytes = 0;
847
+
848
+ if ( aString !== null ) {
849
+ //https://www.jacklmoore.com/notes/rounding-in-javascript/
850
+
851
+ kbytes = Number(Math.round((Buffer.byteLength(aString, encode) / 1024)+'e3')+'e-3'); ; // size in KB (rounded to 3 decimals)
852
+ // 3 decimals is good as 1 byte = .0009KB (rounded to .001) and 5bytes = .0048KB (rounded to .005)
853
+ // Otherwise .0009KB would be rounded to .00 and .0048 rounded to .00
854
+ }
855
+
856
+ return kbytes;
857
+ };
858
+
859
+ /**
860
+ * We can set times and expirations on intervals, such as every
861
+ * 15 seconds (mm:00, mm:15, mm:30, mm:45), every half hour
862
+ * (hh:00:00, hh:30:00), every hour (T00:00:00, T01:00:00), etc.
863
+ * In some cases such as every 2 hours, the interval is calculated
864
+ * from midnight in the timezone specified in timeZoneForInterval
865
+ * Spans of days (such as every two days (48 hours) or every three
866
+ * days (72 hours) are calculated from midnight of the UNIX epoch
867
+ * (January 1, 1970).
868
+ *
869
+ * When a timezone is set in timeZoneForInterval, then there is
870
+ * a slight adjustment made so that the interval lines up with
871
+ * midnight of the "local" time. For example, if an organization
872
+ * is primarily located in the Central Time Zone (or their
873
+ * nightly batch jobs occur at GMT-05:00) then timeZoneForInterval
874
+ * may be set to "America/Chicago" so that midnight in
875
+ * "America/Chicago" may be used for calculations. That keeps
876
+ * every 4 hours on hours 00, 04, 08, 12, 16, etc.
877
+ * @param {number} intervalInSeconds
878
+ * @param {number} timestampInSeconds
879
+ * @returns {number} Next interval in seconds
880
+ */
881
+ static nextIntervalInSeconds(intervalInSeconds, timestampInSeconds = 0 ) {
882
+
883
+ // if no timestamp given, the default timestamp is now()
884
+ if ( timestampInSeconds === 0 ) {
885
+ timestampInSeconds = CacheData.convertTimestampFromMilliToSeconds(Date.now());
886
+ }
887
+
888
+ /* We do an offset conversion by adjusting the timestamp to a "local"
889
+ time. This is purely for calculations and is not used as a "date".
890
+ */
891
+
892
+ // Add in offset so we can calculate from midnight local time
893
+ let offset = (CacheData.getOffsetInMinutes() * 60 ); // convert to seconds
894
+ timestampInSeconds += offset;
895
+
896
+ // convert the seconds into a date
897
+ let date = new Date( CacheData.convertTimestampFromSecondsToMilli(timestampInSeconds) );
898
+
899
+ // https://stackoverflow.com/questions/10789384/round-a-date-to-the-nearest-5-minutes-in-javascript
900
+ let coeff = CacheData.convertTimestampFromSecondsToMilli(intervalInSeconds);
901
+ let rounded = new Date(Math.ceil(date.getTime() / coeff) * coeff);
902
+ let nextInSeconds = CacheData.convertTimestampFromMilliToSeconds(rounded.getTime());
903
+
904
+ // revert the offset so we are looking at UTC
905
+ nextInSeconds -= offset;
906
+
907
+ return nextInSeconds;
908
+ };
909
+
910
+ /**
911
+ * If no parameter is passed, Date.now() is used.
912
+ * @param {number} timestampInMillseconds The timestamp in milliseconds to convert to seconds
913
+ * @returns {number} The timestamp in seconds
914
+ */
915
+ static convertTimestampFromMilliToSeconds (timestampInMillseconds = 0) {
916
+ if (timestampInMillseconds === 0) { timestampInMillseconds = Date.now().getTime(); }
917
+ return Math.ceil(timestampInMillseconds / 1000);
918
+ };
919
+
920
+ /**
921
+ * If no parameter is passed, Date.now() is used.
922
+ * @param {number} timestampInSeconds
923
+ * @returns {number} Timestamp in milliseconds
924
+ */
925
+ static convertTimestampFromSecondsToMilli (timestampInSeconds = 0) {
926
+ let timestampInMilli = 0;
927
+
928
+ if (timestampInSeconds === 0) {
929
+ timestampInMilli = Date.now().getTime();
930
+ } else {
931
+ timestampInMilli = timestampInSeconds * 1000;
932
+ }
933
+
934
+ return timestampInMilli;
935
+ };
936
+
937
+ };
938
+
939
+
940
+
941
+
942
+ /**
943
+ * The Cache object handles reads and writes from the cache.
944
+ * It also acts as a proxy between the app and CacheData which is a private class.
945
+ * This is the actual data object our application can work with and is returned
946
+ * from CachableDataAccess.
947
+ *
948
+ * Before using it must be initialized
949
+ *
950
+ * Cache.init({parameters});
951
+ *
952
+ * Then you can create new objects:
953
+ *
954
+ * const cacheObject = new Cache({id}, {parameters});
955
+ */
956
+ class Cache {
957
+
958
+ static PUBLIC = CacheData.PUBLIC;
959
+ static PRIVATE = CacheData.PRIVATE;
960
+
961
+ static CRYPT_ENCODING = CacheData.CRYPT_ENCODING;
962
+ static PLAIN_ENCODING = CacheData.PLAIN_ENCODING;
963
+
964
+ static STATUS_NO_CACHE = "original";
965
+ static STATUS_EXPIRED = "original:cache-expired";
966
+ static STATUS_CACHE_SAME = "cache:original-same-as-cache";
967
+ static STATUS_CACHE = "cache";
968
+ static STATUS_CACHE_ERROR = "error:cache"
969
+ static STATUS_ORIGINAL_NOT_MODIFIED = "cache:original-not-modified";
970
+ static STATUS_ORIGINAL_ERROR = "error:original";
971
+ static STATUS_FORCED = "original:cache-update-forced";
972
+
973
+ static #idHashAlgorithm = null;
974
+ static #useToolsHash = false;
975
+
976
+ #syncedNowTimestampInSeconds = 0; // consistent time base for calculations
977
+ #syncedLaterTimestampInSeconds = 0; // default expiration if not adjusted
978
+ #idHash = "";
979
+ #status = null;
980
+ #errorCode = 0;
981
+ #store = null;
982
+
983
+ #overrideOriginHeaderExpiration = false;
984
+ #defaultExpirationInSeconds = 60;
985
+ #defaultExpirationExtensionOnErrorInSeconds = 3600;
986
+ #expirationIsOnInterval = false;
987
+ #headersToRetain = [];
988
+
989
+ #hostId = "notset";
990
+ #pathId = "notset";
991
+ #encrypt = true;
992
+
993
+ /**
994
+ * Create a new Cache object
995
+ * @param {object} connection An object that contains data location and connection details. Typically a connection object. It may be of any format with any keys as long as they can uniquely identify this cashed object from others
996
+ * @param {object} cacheProfile An object with some or all of the available parameter settings listed above.
997
+ * @param {boolean} cacheProfile.overrideOriginHeaderExpiration Will we ignore and replace the expires header from origin or will we create our own? Defalue: false
998
+ * @param {number} cacheProfile.defaultExpirationInSeconds In seconds, how long is the default expiration? Default: 60 (60 seconds)
999
+ * @param {number} cacheProfile.defaultExpirationExtensionOnErrorInSeconds In seconds, if there is an error, how long until the error expires from cache? Default: 3600 (5 minutes)
1000
+ * @param {boolean} cacheProfile.expirationIsOnInterval Does the cache expires timer reset on first request, or is the expires set to the clock? (ex. every 10 seconds, every hour, etc) Default: false
1001
+ * @param {Array|string} cacheProfile.headersToRetain Array or comma deliminated string of header keys to keep from the original source to cache and pass to client. Note that there are certain headers such as content type that are always retained. Default: [] (none)
1002
+ * @param {string} cacheProfile.hostId Used for logging. Does not need to be a valid internet host. Any identifier is valid. Default: "notset"
1003
+ * @param {string} cacheProfile.pathId Used for logging. Does not need to be a valid internet path. Should not contain sensitive information. For example, /record/user/488322 should just be /record/user/ to denote a user record was accessed. Default: "notset"
1004
+ * @param {boolean} cacheProfile.encrypt When at rest is the data encrypted? This also corresponds to "public" (encrypted: false) or "private" (encrypted: true) in the cache-control header. Default: true
1005
+ */
1006
+ constructor(connection, cacheProfile = null) {
1007
+
1008
+ // set cacheProfile first - these come from files and fields, so we need to cast them
1009
+ if (cacheProfile !== null) {
1010
+
1011
+ // There is some documentation and template code that uses different names for these cacheProfile - offda - sorry - chadkluck 2023-08-04
1012
+ // https://github.com/chadkluck/npm-chadkluck-cache-data/issues/71
1013
+ if ( "expiresIsOnInterval" in cacheProfile ) { this.#expirationIsOnInterval = Cache.bool(cacheProfile.expiresIsOnInterval); } // we'll accept this for backwards compatibility - chadkluck 2023-08-05
1014
+ if ( "expirationIsOnInterval" in cacheProfile ) { this.#expirationIsOnInterval = Cache.bool(cacheProfile.expirationIsOnInterval); }
1015
+
1016
+ if ( "defaultExpiresInSeconds" in cacheProfile ) { this.#defaultExpirationInSeconds = parseInt(cacheProfile.defaultExpiresInSeconds, 10); } // we'll accept this for backwards compatibility - chadkluck 2023-08-05
1017
+ if ( "defaultExpirationInSeconds" in cacheProfile ) { this.#defaultExpirationInSeconds = parseInt(cacheProfile.defaultExpirationInSeconds, 10); }
1018
+
1019
+ // Host and Path can be confusing as these aren't actually used in the cache, but are used for logging - chadkluck 2023-08-05
1020
+ if ( "host" in cacheProfile ) { this.#hostId = cacheProfile.host; } // we'll accept host for backwards compatibility - chadkluck 2023-08-05
1021
+ if ( "hostId" in cacheProfile ) { this.#hostId = cacheProfile.hostId; } // changed from host to hostId chadkluck 2023-08-05
1022
+ if ( "path" in cacheProfile ) { this.#pathId = cacheProfile.path; } // we'll accept path for backwards compatibility - chadkluck 2023-08-05
1023
+ if ( "pathId" in cacheProfile ) { this.#pathId = cacheProfile.pathId; } // changed from path to pathId chadkluck 2023-08-05
1024
+
1025
+ // Documentation uses a better term of Override rather than ignore - chadkluck 2023-08-05
1026
+ if ( "ignoreOriginHeaderExpires" in cacheProfile ) { this.#overrideOriginHeaderExpiration = Cache.bool(cacheProfile.ignoreOriginHeaderExpires); } // we'll accept this for backwards compatibility - chadkluck 2023-08-05
1027
+ if ( "ignoreOriginHeaderExpiration" in cacheProfile ) { this.#overrideOriginHeaderExpiration = Cache.bool(cacheProfile.ignoreOriginHeaderExpiration); } // we'll accept this for backwards compatibility - chadkluck 2023-08-05
1028
+ if ( "overrideOriginHeaderExpiration" in cacheProfile ) { this.#overrideOriginHeaderExpiration = Cache.bool(cacheProfile.overrideOriginHeaderExpiration); }
1029
+
1030
+ // We are using expiration rather than expires - chadkluck 2023-08-05
1031
+ if ( "defaultExpiresExtensionOnErrorInSeconds" in cacheProfile ) { this.#defaultExpirationExtensionOnErrorInSeconds = parseInt(cacheProfile.defaultExpiresExtensionOnErrorInSeconds, 10); }
1032
+ if ( "defaultExpirationExtensionOnErrorInSeconds" in cacheProfile ) { this.#defaultExpirationExtensionOnErrorInSeconds = parseInt(cacheProfile.defaultExpirationExtensionOnErrorInSeconds, 10); }
1033
+
1034
+ // set cacheProfile using the accepted property names
1035
+ if ( "headersToRetain" in cacheProfile ) { this.#headersToRetain = this.#parseHeadersToRetain(cacheProfile.headersToRetain); }
1036
+ if ( "encrypt" in cacheProfile ) { this.#encrypt = Cache.bool(cacheProfile.encrypt); }
1037
+
1038
+ }
1039
+
1040
+ // now set cache info
1041
+ this.#idHash = Cache.generateIdHash(connection);
1042
+ this.#syncedNowTimestampInSeconds = CacheData.convertTimestampFromMilliToSeconds(Date.now());
1043
+ this.#syncedLaterTimestampInSeconds = this.#syncedNowTimestampInSeconds + this.#defaultExpirationInSeconds; // now + default cache time
1044
+
1045
+ };
1046
+
1047
+ /**
1048
+ * Initialize all data common to all Cache objects.
1049
+ * Needs to be used at the application boot,
1050
+ * NOT per request or after new Cache().
1051
+ * Environment variables can be used to set the S3 bucket, DynamoDb location, etc.
1052
+ * Use Cache.info() to check init values.
1053
+ *
1054
+ * Sample param object:
1055
+ * @example
1056
+ * cache.Cache.init({
1057
+ * dynamoDbTable: process.env.CacheData_DynamoDbTable,
1058
+ * s3Bucket: process.env.CacheData_S3Bucket,
1059
+ * secureDataAlgorithm: process.env.CacheData_CryptSecureDataAlgorithm,
1060
+ * secureDataKey: Buffer.from(params.app.crypt_secureDataKey, cache.Cache.CRYPT_ENCODING),
1061
+ * idHashAlgorithm: process.env.CacheData_CryptIdHashAlgorithm,
1062
+ * DynamoDbMaxCacheSize_kb: parseInt(process.env.CacheData_DynamoDb_maxCacheSize_kb, 10),
1063
+ * purgeExpiredCacheEntriesAfterXHours: parseInt(process.env.CacheData_PurgeExpiredCacheEntriesAfterXHours, 10),
1064
+ * timeZoneForInterval: process.env.CacheData_TimeZoneForInterval // if caching on interval, we need a timezone to account for calculating hours, days, and weeks. List: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
1065
+ * });
1066
+ *
1067
+ * @param {Object} parameters
1068
+ * @param {string} parameters.dynamoDbTable
1069
+ * @param {string} parameters.s3Bucket
1070
+ * @param {string} parameters.secureDataAlgorithm
1071
+ * @param {string} parameters.secureDataKey
1072
+ * @param {number} parameters.DynamoDbMaxCacheSize_kb
1073
+ * @param {number} parameters.purgeExpiredCacheEntriesAfterXHours
1074
+ * @param {string} parameters.timeZoneForInterval
1075
+ */
1076
+ static init(parameters) {
1077
+ if ( "idHashAlgorithm" in parameters ) { this.#idHashAlgorithm = parameters.idHashAlgorithm; } else { tools.DebugAndLog.error("parameters.idHashAlgorithm not set in Cache.init()")};
1078
+ if ("useToolsHash" in parameters ) { this.#useToolsHash = Boolean(parameters.useToolsHash); }
1079
+
1080
+ CacheData.init(parameters);
1081
+ };
1082
+
1083
+ /**
1084
+ * Returns all the common information such as hash algorithm, s3 bucket,
1085
+ * dynamo db location, etc.
1086
+ * @returns {{
1087
+ * idHashAlgorithm: string,
1088
+ * dynamoDbTable: string,
1089
+ * s3Bucket: string,
1090
+ * secureDataAlgorithm: string,
1091
+ * secureDataKey: string,
1092
+ * DynamoDbMaxCacheSize_kb: number,
1093
+ * purgeExpiredCacheEntriesAfterXHours: number,
1094
+ * timeZoneForInterval: string,
1095
+ * offsetInMinutes: number
1096
+ * }}
1097
+ */
1098
+ static info() {
1099
+ return Object.assign({ idHashAlgorithm: this.#idHashAlgorithm }, CacheData.info()); // merge into 1 object and return
1100
+ };
1101
+
1102
+ /**
1103
+ *
1104
+ * @returns {object} Test data of nextIntervalInSeconds method
1105
+ */
1106
+ static testInterval () {
1107
+ let ts = CacheData.convertTimestampFromMilliToSeconds(Date.now());
1108
+ return {
1109
+ "info": Cache.info(),
1110
+ "tests": {
1111
+ "start": (new Date( CacheData.convertTimestampFromSecondsToMilli(ts))),
1112
+ "sec_15": (new Date(Cache.nextIntervalInSeconds(15, ts)*1000)),
1113
+ "sec_30": (new Date(Cache.nextIntervalInSeconds(30, ts)*1000)),
1114
+ "sec_60": (new Date(Cache.nextIntervalInSeconds(60, ts)*1000)),
1115
+ "min_05": (new Date(Cache.nextIntervalInSeconds(5*60, ts)*1000)),
1116
+ "min_10": (new Date(Cache.nextIntervalInSeconds(10*60, ts)*1000)),
1117
+ "min_15": (new Date(Cache.nextIntervalInSeconds(15*60, ts)*1000)),
1118
+ "min_30": (new Date(Cache.nextIntervalInSeconds(30*60, ts)*1000)),
1119
+ "min_60": (new Date(Cache.nextIntervalInSeconds(60*60, ts)*1000)),
1120
+ "hrs_02": (new Date(Cache.nextIntervalInSeconds(2*60*60, ts)*1000)),
1121
+ "hrs_03": (new Date(Cache.nextIntervalInSeconds(3*60*60, ts)*1000)),
1122
+ "hrs_04": (new Date(Cache.nextIntervalInSeconds(4*60*60, ts)*1000)),
1123
+ "hrs_05": (new Date(Cache.nextIntervalInSeconds(5*60*60, ts)*1000)),
1124
+ "hrs_06": (new Date(Cache.nextIntervalInSeconds(6*60*60, ts)*1000)),
1125
+ "hrs_08": (new Date(Cache.nextIntervalInSeconds(8*60*60, ts)*1000)),
1126
+ "hrs_12": (new Date(Cache.nextIntervalInSeconds(12*60*60, ts)*1000)),
1127
+ "hrs_24": (new Date(Cache.nextIntervalInSeconds(24*60*60, ts)*1000)),
1128
+ "hrs_48": (new Date(Cache.nextIntervalInSeconds(48*60*60, ts)*1000)),
1129
+ "hrs_72": (new Date(Cache.nextIntervalInSeconds(72*60*60, ts)*1000)),
1130
+ "hrs_96": (new Date(Cache.nextIntervalInSeconds(96*60*60, ts)*1000)),
1131
+ "hrs_120": (new Date(Cache.nextIntervalInSeconds(120*60*60, ts)*1000))
1132
+ }
1133
+ };
1134
+ };
1135
+
1136
+ /**
1137
+ * When testing a variable as a boolean, 0, false, and null are false,
1138
+ * but the string "false" is true. Since we can be dealing with JSON data,
1139
+ * query parameters, and strings coded as "false" we want to include the
1140
+ * string "false" as false.
1141
+ * This function only adds "false" to the list of values already considered
1142
+ * false by JavaScript
1143
+ * @param {*} value A value you want to turn to boolean
1144
+ * @returns {boolean} Does the value equal false according to JavaScript evaluation rules?
1145
+ */
1146
+ static bool (value) {
1147
+
1148
+ if ( typeof value === 'string') { value = value.toLowerCase(); }
1149
+
1150
+ // Boolean("false") is true so we need to code for it. As long as it is not "false", trust Boolean()
1151
+ return (( value !== "false") ? Boolean(value) : false );
1152
+ };
1153
+
1154
+ /**
1155
+ * Generate an etag based on an id and content body
1156
+ * @param {string} idHash Hashed content identifier. For web pages this a hash of host, path, query, etc.
1157
+ * @param {string} content Content. usually body and static headers
1158
+ * @returns {string} An etag specific to that content and id
1159
+ */
1160
+ static generateEtag(idHash, content) {
1161
+ return CacheData.generateEtag(idHash, content);
1162
+ };
1163
+
1164
+ /**
1165
+ * To make submitted key comparison easier against a standard set of keys,
1166
+ * lowercase keys in the object.
1167
+ * @param {object} objectWithKeys Object we want to lowercase keys on
1168
+ * @returns {object} Object with lowercase keys
1169
+ */
1170
+ static lowerCaseKeys(objectWithKeys) {
1171
+ return CacheData.lowerCaseKeys(objectWithKeys);
1172
+ };
1173
+
1174
+ /**
1175
+ * Generate an internet formatted date such as those used in headers.
1176
+ *
1177
+ * Example: "Wed, 28 Jul 2021 12:24:11 GMT"
1178
+ *
1179
+ * @param {number} timestamp Unix timestamp in seconds or milliseconds.
1180
+ * @param {boolean} inMilliseconds Set to true if timestamp is in milliseconds. Default is false.
1181
+ * @returns {string} Formatted date/time such as Wed, 28 Jul 2021 12:24:11 GMT
1182
+ */
1183
+ static generateInternetFormattedDate(timestamp, inMilliseconds = false) {
1184
+ return CacheData.generateInternetFormattedDate(timestamp, inMilliseconds);
1185
+ };
1186
+
1187
+ /**
1188
+ * Uses object-hash to create a hash of an object passed to it
1189
+ * Object-hash respects object structures and arrays and performs a sort to
1190
+ * normalize objects so that objects with the same key/value structure are
1191
+ * identified as such. For example:
1192
+ * {host: "example.com", path: "/api"} === {path: "/api", host: "example.com"}
1193
+ *
1194
+ * You can also pass in a string such as "MYID-03-88493" if your id is not
1195
+ * query based.
1196
+ *
1197
+ * Note: Arrays are sorted alphabetically. So [1,2,3] will be same as
1198
+ * [3,1,2] and ["A","B","C"] will be same as ["B","C","A"] so if the order
1199
+ * of the array matters it is recommended to perform a .join prior. This is
1200
+ * so that:
1201
+ * {query: {types: "db,contact,guides"} } === {query: {types: "contact,guides,db"} }
1202
+ * example.com/?types=db,contact,guides === example.com/?types=contact,guides,db
1203
+ *
1204
+ * You can pass in an object containing request header and query param
1205
+ * represented as objects.
1206
+ *
1207
+ * As an example, CacheableDataAccess() combines 3 of the parameter objects
1208
+ * passed to it.
1209
+ * query, connection, and cachePolicy are pased as an object by
1210
+ * CacheableDataAccess() for Cache() to create a hashed id:
1211
+ * { query: query, connection: connection, cachePolicy: cachePolicy };
1212
+ *
1213
+ * Passing an array of objects such as:
1214
+ * [ query, connection, cachePolicy ]
1215
+ * also works. object-hash is REALLY cool and magical
1216
+ *
1217
+ * Uses object-hash: https://www.npmjs.com/package/object-hash
1218
+ * Git: https://github.com/puleos/object-hash
1219
+ *
1220
+ * Make sure it is installed in app/node_modules with an entry in app/package.json
1221
+ * "dependencies": {
1222
+ * "object-hash": "^2.2.0"
1223
+ * }
1224
+ *
1225
+ * @param {Object|Array|string} idObject Object, Array, or string to hash. Object may contain a single value with a text string, or complex http request broken down into parts
1226
+ * @returns {string} A hash representing the object (Algorithm used is set in Cache object constructor)
1227
+ */
1228
+ static generateIdHash(idObject) {
1229
+
1230
+ // Helper function to remove functions from an object
1231
+ const removeFunctions = (obj) => {
1232
+ const newObj = {};
1233
+ for (const [key, value] of Object.entries(obj)) {
1234
+ if (typeof value === 'function') {
1235
+ continue; // skip functions
1236
+ }
1237
+ if (typeof value === 'object' && value !== null) {
1238
+ newObj[key] = removeFunctions(value); // recursively handle nested objects
1239
+ } else {
1240
+ newObj[key] = value;
1241
+ }
1242
+ }
1243
+ return newObj;
1244
+ };
1245
+
1246
+ // Create clean object without functions first
1247
+ const cleanObject = removeFunctions(idObject);
1248
+
1249
+ // Now safe to use structuredClone - deep clone idObject so we don't change the original
1250
+ const clonedIdObject = structuredClone(cleanObject);
1251
+
1252
+ // set salt to process.env.AWS_LAMBDA_FUNCTION_NAME if it exists, otherwise use ""
1253
+ const salt = process.env?.AWS_LAMBDA_FUNCTION_NAME || "";
1254
+
1255
+ // remove connection.options from clonedIdObject
1256
+ if ( clonedIdObject.connection?.options ) { delete clonedIdObject.connection.options; }
1257
+
1258
+ // use the built-in hashing from CacheData tools
1259
+ if ( this.#useToolsHash ) { return tools.hashThisData(this.#idHashAlgorithm, clonedIdObject, {salt}); }
1260
+
1261
+ // use the external package object-hash settings
1262
+ const objHashSettings = {
1263
+ algorithm: this.#idHashAlgorithm,
1264
+ encoding: "hex", // default, but we'll list it here anyway as it is important for this use case
1265
+ respectType: true, // default, but we'll list it here anyway as it is important for this use case
1266
+ unorderedSets: true, // default, but we'll list it here anyway as it is important for this use case
1267
+ unorderedObjects: true, // default, but we'll list it here anyway as it is important for this use case
1268
+ unorderedArrays: true // default is false but we want true - would be a problem if array sequence mattered, but not in this use case
1269
+ };
1270
+
1271
+ // there is no salt in object-hash, so we add it to a property that would be least likely to conflict
1272
+ clonedIdObject.THIS_IS_SALT_FOR_CK_CACHE_DATA_ID_HASH = salt;
1273
+
1274
+ return objHash(clonedIdObject, objHashSettings);
1275
+
1276
+ };
1277
+
1278
+ /**
1279
+ * Converts an array to a string using a join. However, it is fluid in case
1280
+ * we might also be passed an id that is already a string.
1281
+ * @param {Array|string} identifierArrayOrString An array we wish to join together as an id. (also could be a string which we won't touch)
1282
+ * @param {string} glue The glue or delimiter to place between the array elements once it is in string form
1283
+ * @returns {string} The array in string form delimited by the glue.
1284
+ */
1285
+ static multipartId (identifierArrayOrString, glue = "-") {
1286
+ let id = null;
1287
+ if ( Array.isArray(identifierArrayOrString) || typeof identifierArrayOrString === 'string') {
1288
+ id = ( Array.isArray(identifierArrayOrString) ) ? identifierArrayOrString.join(glue) : identifierArrayOrString;
1289
+ }
1290
+ return id;
1291
+ };
1292
+
1293
+ /**
1294
+ * Uses Date.parse() but returns seconds instead of milliseconds.
1295
+ * Takes a date string (such as "2011-10-10T14:48:00") and returns the number of seconds since January 1, 1970, 00:00:00 UTC
1296
+ * @param {string} date
1297
+ * @returns {number} The date in seconds since January 1, 1970, 00:00:00 UTC
1298
+ */
1299
+ static parseToSeconds(date) {
1300
+ let timestampInSeconds = 0;
1301
+ try {
1302
+ timestampInSeconds = CacheData.convertTimestampFromMilliToSeconds( Date.parse(date) );
1303
+ } catch (error) {
1304
+ tools.DebugAndLog.error(`Cannot parse date/time: ${date} ${error.message}`, error.stack);
1305
+ }
1306
+ return timestampInSeconds;
1307
+ };
1308
+
1309
+ /**
1310
+ * We can set times and expirations on intervals, such as every
1311
+ * 15 seconds (mm:00, mm:15, mm:30, mm:45), every half hour
1312
+ * (hh:00:00, hh:30:00), every hour (T00:00:00, T01:00:00), etc.
1313
+ * In some cases such as every 2 hours, the interval is calculated
1314
+ * from midnight in the timezone specified in timeZoneForInterval
1315
+ * Spans of days (such as every two days (48 hours) or every three
1316
+ * days (72 hours) are calculated from midnight of the UNIX epoch
1317
+ * (January 1, 1970).
1318
+ *
1319
+ * When a timezone is set in timeZoneForInterval, then there is
1320
+ * a slight adjustment made so that the interval lines up with
1321
+ * midnight of the "local" time. For example, if an organization
1322
+ * is primarily located in the Central Time Zone (or their
1323
+ * nightly batch jobs occur at GMT-05:00) then timeZoneForInterval
1324
+ * may be set to "America/Chicago" so that midnight in
1325
+ * "America/Chicago" may be used for calculations. That keeps
1326
+ * every 4 hours on hours 00, 04, 08, 12, 16, etc.
1327
+ * @param {number} intervalInSeconds
1328
+ * @param {number} timestampInSeconds
1329
+ * @returns {number} Next interval in seconds
1330
+ */
1331
+ static nextIntervalInSeconds(intervalInSeconds, timestampInSeconds = 0) {
1332
+ return CacheData.nextIntervalInSeconds(intervalInSeconds, timestampInSeconds);
1333
+ };
1334
+
1335
+
1336
+ /**
1337
+ * Calculate the number of Kilobytes in memory a String takes up.
1338
+ * This function first calculates the number of bytes in the String using
1339
+ * Buffer.byteLength() and then converts it to KB = (bytes / 1024)
1340
+ * @param {string} aString A string to calculate on
1341
+ * @param {string} encode What character encoding should be used? Default is "utf8"
1342
+ * @returns String size in estimated KB
1343
+ */
1344
+ static calculateKBytes ( aString, encode = CacheData.PLAIN_ENCODING ) {
1345
+ return CacheData.calculateKBytes( aString, encode);
1346
+ };
1347
+
1348
+ /**
1349
+ * Converts a comma delimited string or an array to an array with all
1350
+ * lowercase values. Can be used to pass a comma delimited string
1351
+ * for conversion to an array that will then be used as (lowercase) keys.
1352
+ * @param {string|Array} list
1353
+ * @returns Array with lowercase values
1354
+ */
1355
+ static convertToLowerCaseArray(list) {
1356
+
1357
+ // if it is an array, we'll convert to csv string
1358
+ if (Array.isArray(list)) {
1359
+ list = list.join(',');
1360
+ }
1361
+
1362
+ // lowercase the string and then convert to an array
1363
+ return list.toLowerCase().split(',');
1364
+ };
1365
+
1366
+ /**
1367
+ * Takes either a csv string or an Array. It will return an array
1368
+ * with lowercase values to be used as header keys
1369
+ * @param {string|Array} list
1370
+ * @returns Array with lowercase values
1371
+ */
1372
+ #parseHeadersToRetain (list) {
1373
+ return Cache.convertToLowerCaseArray(list);
1374
+ };
1375
+
1376
+ profile () {
1377
+ return {
1378
+ overrideOriginHeaderExpiration: this.#overrideOriginHeaderExpiration,
1379
+ defaultExpirationInSeconds: this.#defaultExpirationInSeconds,
1380
+ defaultExpirationExtensionOnErrorInSeconds: this.#defaultExpirationExtensionOnErrorInSeconds,
1381
+ expirationIsOnInterval: this.#expirationIsOnInterval,
1382
+ headersToRetain: this.#headersToRetain,
1383
+ hostId: this.#hostId,
1384
+ pathId: this.#pathId,
1385
+ encrypt: this.#encrypt
1386
+ }
1387
+ };
1388
+
1389
+ /**
1390
+ *
1391
+ * @returns {Promise<CacheDataFormat>}
1392
+ */
1393
+ async read () {
1394
+
1395
+ return new Promise(async (resolve, reject) => {
1396
+
1397
+ if ( this.#store !== null ) {
1398
+ resolve(this.#store);
1399
+ } else {
1400
+ try {
1401
+ this.#store = await CacheData.read(this.#idHash, this.#syncedLaterTimestampInSeconds);
1402
+ this.#status = ( this.#store.cache.statusCode === null ) ? Cache.STATUS_NO_CACHE : Cache.STATUS_CACHE;
1403
+
1404
+ tools.DebugAndLog.debug(`Cache Read status: ${this.#status}`);
1405
+
1406
+ resolve(this.#store);
1407
+ } catch (error) {
1408
+ this.#store = CacheData.format(this.#syncedLaterTimestampInSeconds);
1409
+ this.#status = Cache.STATUS_CACHE_ERROR;
1410
+
1411
+ tools.DebugAndLog.error(`Cache Read: Cannot read cached data for ${this.#idHash}: ${error.message}`, error.stack);
1412
+
1413
+ reject(this.#store);
1414
+ };
1415
+ }
1416
+
1417
+ });
1418
+
1419
+ };
1420
+
1421
+ test() {
1422
+ return {
1423
+ get: this.get(),
1424
+ getStatus: this.getStatus(),
1425
+ getETag: this.getETag(),
1426
+ getLastModified: this.getLastModified(),
1427
+ getExpires: this.getExpires(),
1428
+ getExpiresGMT: this.getExpiresGMT(),
1429
+ getHeaders: this.getHeaders(),
1430
+ getSyncedNowTimestampInSeconds: this.getSyncedNowTimestampInSeconds(),
1431
+ getBody: this.getBody(),
1432
+ getIdHash: this.getIdHash(),
1433
+ getClassification: this.getClassification(),
1434
+ needsRefresh: this.needsRefresh(),
1435
+ isExpired: this.isExpired(),
1436
+ isEmpty: this.isEmpty(),
1437
+ isPrivate: this.isPrivate(),
1438
+ isPublic: this.isPublic(),
1439
+ currentStatus: this.getStatus(),
1440
+ calculateDefaultExpires: this.calculateDefaultExpires()
1441
+ };
1442
+ };
1443
+
1444
+ /**
1445
+ *
1446
+ * @returns {CacheDataFormat}
1447
+ */
1448
+ get() {
1449
+ return this.#store;
1450
+ };
1451
+
1452
+ /**
1453
+ *
1454
+ * @returns {string}
1455
+ */
1456
+ getSourceStatus() {
1457
+ return this.#status;
1458
+ };
1459
+
1460
+ /**
1461
+ *
1462
+ * @returns {string}
1463
+ */
1464
+ getETag() {
1465
+ return this.getHeader("etag");
1466
+ };
1467
+
1468
+ /**
1469
+ *
1470
+ * @returns {string} The falue of the cached header field last-modified
1471
+ */
1472
+ getLastModified() {
1473
+ return this.getHeader("last-modified");
1474
+ };
1475
+
1476
+ /**
1477
+ *
1478
+ * @returns {number} Expiration timestamp in seconds
1479
+ */
1480
+ getExpires() {
1481
+ let exp = (this.#store !== null) ? this.#store.cache.expires : 0;
1482
+ return exp;
1483
+ };
1484
+
1485
+ /**
1486
+ * Get the expiration as an internet formatted date used in headers.
1487
+ *
1488
+ * Example: "Wed, 28 Jul 2021 12:24:11 GMT"
1489
+ *
1490
+ * @returns {string} The expiration formated for use in headers. Same as expires header.
1491
+ */
1492
+ getExpiresGMT() {
1493
+ return this.getHeader("expires");
1494
+ };
1495
+
1496
+ /**
1497
+ *
1498
+ * @returns {number} The calculated number of seconds from now until expires
1499
+ */
1500
+ calculateSecondsLeftUntilExpires() {
1501
+ let secondsLeftUntilExpires = this.getExpires() - CacheData.convertTimestampFromMilliToSeconds( Date.now() );
1502
+ if (secondsLeftUntilExpires < 0) { secondsLeftUntilExpires = 0; }
1503
+
1504
+ return secondsLeftUntilExpires;
1505
+ };
1506
+
1507
+ /**
1508
+ * Example: public, max-age=123456
1509
+ * @returns {string} The value for cache-control header
1510
+ */
1511
+ getCacheControlHeaderValue() {
1512
+ return this.getClassification() +", max-age="+this.calculateSecondsLeftUntilExpires();
1513
+ };
1514
+
1515
+ /**
1516
+ *
1517
+ * @returns {object|null} All the header key/value pairs for the cached object
1518
+ */
1519
+ getHeaders() {
1520
+ return (this.#store !== null && "headers" in this.#store.cache) ? this.#store.cache.headers : null;
1521
+ };
1522
+
1523
+ /**
1524
+ *
1525
+ * @returns {string|null} The status code of the cache object
1526
+ */
1527
+ getStatusCode() {
1528
+ return (this.#store !== null && "statusCode" in this.#store.cache) ? this.#store.cache.statusCode : null;
1529
+ };
1530
+
1531
+ /**
1532
+ *
1533
+ * @returns {number} Current error code for this cache
1534
+ */
1535
+ getErrorCode() {
1536
+ return this.#errorCode;
1537
+ };
1538
+
1539
+ /**
1540
+ * Classification is used in the cache header to note how the data returned
1541
+ * should be treated in the cache. If it is private then it should be
1542
+ * protected.
1543
+ * @returns {string} Based on whether the cache is stored as encrypted, returns "private" (encrypted) or "public" (not encrypted)
1544
+ */
1545
+ getClassification() {
1546
+ return (this.#encrypt ? Cache.PRIVATE : Cache.PUBLIC );
1547
+ };
1548
+
1549
+ /**
1550
+ *
1551
+ * @returns {number} The timestamp in seconds of when the object was created and used for currency logic of the cache. (used as comparasion against expiration and for creating new expirations)
1552
+ */
1553
+ getSyncedNowTimestampInSeconds() {
1554
+ return this.#syncedNowTimestampInSeconds;
1555
+ };
1556
+
1557
+ /**
1558
+ *
1559
+ * @param {string} key The header key to access
1560
+ * @returns {string|number|null} The value assigned to the provided header key. null if it doesn't exist
1561
+ */
1562
+ getHeader(key) {
1563
+ let headers = this.getHeaders();
1564
+ return ( headers !== null && key in headers) ? headers[key] : null
1565
+ };
1566
+
1567
+ /**
1568
+ *
1569
+ * @param {boolean} parseBody If set to true then JSON decode will be used on the body before returning.
1570
+ * @returns {string|object|null} A string (as is) which could be encoded JSON but we want to leave it that way, an object if parseBody is set to true and it is parsable by JSON, or null if body is null
1571
+ */
1572
+ getBody(parseBody = false) {
1573
+ let body = (this.#store !== null) ? this.#store.cache.body : null;
1574
+ let bodyToReturn = null;
1575
+
1576
+ try {
1577
+ bodyToReturn = (body !== null && parseBody) ? JSON.parse(body) : body;
1578
+ } catch (error) {
1579
+ tools.DebugAndLog.error(`Cache.getBody() parse error: ${error.message}`, error.stack);
1580
+ tools.DebugAndLog.debug("Error parsing body", body);
1581
+ };
1582
+
1583
+ return (( bodyToReturn !== null) ? bodyToReturn : body );
1584
+ };
1585
+
1586
+ /**
1587
+ * Returns a plain data response in the form of an object. If a full HTTP
1588
+ * response is needed use generateResponseForAPIGateway()
1589
+ * @param {boolean} parseBody If true we'll return body as object
1590
+ * @returns {{statusCode: string, headers: object, body: string|object}} a plain data response in the form of an object
1591
+ */
1592
+ getResponse(parseBody = false) {
1593
+ let response = null;
1594
+
1595
+ if (this.#store !== null) {
1596
+ response = {
1597
+ statusCode: this.getStatusCode(),
1598
+ headers: this.getHeaders(),
1599
+ body: ( this.getBody(parseBody))
1600
+ };
1601
+ }
1602
+
1603
+ return response;
1604
+ };
1605
+
1606
+ /**
1607
+ *
1608
+ * @param {object} parameters
1609
+ * @returns {{statusCode: string, headers: object, body: string}}
1610
+ */
1611
+ generateResponseForAPIGateway( parameters ) {
1612
+
1613
+ const ifNoneMatch = ( ("ifNoneMatch" in parameters) ? parameters.ifNoneMatch : null);
1614
+ const ifModifiedSince = ( ("ifModifiedSince" in parameters) ? parameters.ifModifiedSince : null);
1615
+
1616
+ const response = this.getResponse(false);
1617
+
1618
+ const additionalHeaders = {
1619
+ "access-control-allow-origin": "*", // we've already checked referer access, and since this can only list one host it presents issues if it can be used across a set of hosts
1620
+ "cache-control": this.getCacheControlHeaderValue(),
1621
+ "x-cprxy-data-source": this.getSourceStatus()
1622
+ };
1623
+
1624
+ // see if the client sent conditionals to elicit a 304 not modified and respond accordingly
1625
+ if (
1626
+ (ifNoneMatch !== null && "etag" in response.headers && ifNoneMatch === response.headers.etag)
1627
+ || (ifModifiedSince !== null && "last-modified" in response.headers && Date.parse(ifModifiedSince) >= Date.parse(response.headers['last-modified']) )
1628
+ ) {
1629
+ // etag and last-modified match, so the client has the most recent copy in it's cache
1630
+ response.statusCode = "304"; // return a Not Modified
1631
+ response.body = null;
1632
+ }
1633
+
1634
+ /*
1635
+ Note: The response for an OK (200) status can be empty ("")
1636
+ However, if a response code is not allowed to return a body, it is set
1637
+ to null to signify that it should not be included in the response and
1638
+ filtered out at this step.
1639
+ */
1640
+
1641
+ // set the statusCode if null
1642
+ if (response.statusCode === null) {
1643
+ response.statusCode = "200";
1644
+ }
1645
+
1646
+ response.headers = Object.assign(response.headers, additionalHeaders);
1647
+
1648
+ return response;
1649
+
1650
+ };
1651
+
1652
+ /**
1653
+ *
1654
+ * @returns {string}
1655
+ */
1656
+ getIdHash() {
1657
+ return this.#idHash;
1658
+ };
1659
+
1660
+ /**
1661
+ *
1662
+ * @returns {boolean}
1663
+ */
1664
+ needsRefresh() {
1665
+ return (this.isExpired() || this.isEmpty());
1666
+ };
1667
+
1668
+ /**
1669
+ *
1670
+ * @returns {boolean}
1671
+ */
1672
+ isExpired() {
1673
+ return ( CacheData.convertTimestampFromSecondsToMilli(this.getExpires()) <= Date.now());
1674
+ };
1675
+
1676
+ /**
1677
+ *
1678
+ * @returns {boolean}
1679
+ */
1680
+ isEmpty() {
1681
+ return (this.#store.cache.statusCode === null);
1682
+ };
1683
+
1684
+ /**
1685
+ *
1686
+ * @returns {boolean}
1687
+ */
1688
+ isPrivate() {
1689
+ return this.#encrypt;
1690
+ };
1691
+
1692
+ /**
1693
+ *
1694
+ * @returns {boolean}
1695
+ */
1696
+ isPublic() {
1697
+ return !this.#encrypt;
1698
+ };
1699
+
1700
+ /**
1701
+ *
1702
+ * @param {string} reason Reason for extending, either Cache.STATUS_ORIGINAL_ERROR or Cache.STATUS_ORIGINAL_NOT_MODIFIED
1703
+ * @param {number} seconds
1704
+ * @param {number} errorCode
1705
+ */
1706
+ extendExpires(reason, seconds = 0, errorCode = 0) {
1707
+
1708
+ try {
1709
+
1710
+ let cache = this.#store.cache;
1711
+
1712
+ // we will extend based on error extention if in error, we'll look at passed seconds and non-error default later
1713
+ if (seconds === 0 && reason === Cache.STATUS_ORIGINAL_ERROR) {
1714
+ seconds = this.#defaultExpirationExtensionOnErrorInSeconds;
1715
+ }
1716
+
1717
+ // if the cache exists, we'll extend it
1718
+ if ( cache !== null ) {
1719
+ // statusCode
1720
+ let statusCode = (cache.statusCode !== null) ? cache.statusCode : errorCode ;
1721
+
1722
+ // we are going to create a new expires header, so delete it if it exists so we start from now()
1723
+ if (cache.headers !== null && "expires" in cache.headers) { delete cache.headers.expires; }
1724
+
1725
+ // calculate the new expires based on default (seconds === 0) or now() + seconds passed to this function
1726
+ let expires = (seconds === 0) ? this.calculateDefaultExpires() : this.#syncedNowTimestampInSeconds + seconds;
1727
+
1728
+ // if a reason was passed, use it only if it is a valid reason for extending. Otherwise null
1729
+ let status = (reason === Cache.STATUS_ORIGINAL_ERROR || reason === Cache.STATUS_ORIGINAL_NOT_MODIFIED) ? reason : null;
1730
+
1731
+ // if we received an error, add it in in case we want to evaluate further
1732
+ if (errorCode >= 400) { this.#errorCode = errorCode; }
1733
+
1734
+ // perform the update with existing info, but new expires and status
1735
+ this.update( cache.body, cache.headers, statusCode, expires, status);
1736
+ } else {
1737
+ tools.DebugAndLog.debug("Cache is null. Nothing to extend.");
1738
+ }
1739
+
1740
+ } catch (error) {
1741
+ tools.DebugAndLog.error(`Unable to extend cache: ${error.message}`, error.stack);
1742
+ };
1743
+
1744
+ };
1745
+
1746
+ /**
1747
+ *
1748
+ * @returns {number}
1749
+ */
1750
+ calculateDefaultExpires() {
1751
+ return (this.#expirationIsOnInterval) ? Cache.nextIntervalInSeconds(this.#defaultExpirationInSeconds, this.#syncedNowTimestampInSeconds) : this.#syncedLaterTimestampInSeconds;
1752
+ };
1753
+
1754
+ /**
1755
+ *
1756
+ * @returns {string}
1757
+ */
1758
+ getStatus() {
1759
+ return this.#status;
1760
+ };
1761
+
1762
+ /**
1763
+ * Store data in cache. Returns a representation of data stored in the cache
1764
+ * @param {object} body
1765
+ * @param {object} headers Any headers you want to pass along, including last-modified, etag, and expires. Note that if expires is included as a header here, then it will override the expires paramter passed to .update()
1766
+ * @param {number} statusCode Status code of original request
1767
+ * @param {number} expires Expiration unix timestamp in seconds
1768
+ * @returns {CacheDataFormat} Representation of data stored in cache
1769
+ */
1770
+ update (body, headers, statusCode = 200, expires = 0, status = null) {
1771
+
1772
+ const prev = {
1773
+ eTag: this.getETag(),
1774
+ modified: this.getLastModified(),
1775
+ expired: this.isExpired(),
1776
+ empty: this.isEmpty()
1777
+ };
1778
+
1779
+ // lowercase all the header keys so we can evaluate each
1780
+ headers = Cache.lowerCaseKeys(headers);
1781
+
1782
+ /* Bring in headers
1783
+ We'll keep the etag and last-modified. Also any specified
1784
+ */
1785
+ let defaultHeadersToRetain = [
1786
+ "content-type",
1787
+ "etag",
1788
+ "last-modified",
1789
+ "ratelimit-limit",
1790
+ "ratelimit-remaining",
1791
+ "ratelimit-reset",
1792
+ "x-ratelimit-limit",
1793
+ "x-ratelimit-remaining",
1794
+ "x-ratelimit-reset",
1795
+ "retry-after"
1796
+ ];
1797
+
1798
+ // combine the standard headers with the headers specified for endpoint in custom/policies.json
1799
+ let ptHeaders = [].concat(this.#headersToRetain, defaultHeadersToRetain);
1800
+
1801
+ // lowercase the headers we are looking for
1802
+ let passThrough = ptHeaders.map(element => {
1803
+ return element.toLowerCase();
1804
+ });
1805
+
1806
+ let headersForCache = {};
1807
+
1808
+ // retain specified headers
1809
+ passThrough.forEach(function( key ) {
1810
+ if (key in headers) { headersForCache[key] = headers[key]; }
1811
+ });
1812
+
1813
+ // we'll set the default expires, in case the expires in header does not work out, or we don't use the header expires
1814
+ if ( isNaN(expires) || expires === 0) {
1815
+ expires = this.calculateDefaultExpires();
1816
+ }
1817
+
1818
+ // get the expires and max age (as timestamp)from headers if we don't insist on overriding
1819
+ // unlike etag and last-modified, we won't move them over, but let the expires param in .update() do the talking
1820
+ if ( !this.#overrideOriginHeaderExpiration && ("expires" in headers || ("cache-control" in headers && headers['cache-control'].includes("max-age") ))) {
1821
+
1822
+ let age = this.#syncedNowTimestampInSeconds;
1823
+ let exp = this.#syncedNowTimestampInSeconds;
1824
+
1825
+ if ("cache-control" in headers && headers['cache-control'].includes("max-age")) {
1826
+ // extract max-age
1827
+ let cacheControl = headers['cache-control'].split(",");
1828
+ for(const p of cacheControl) {
1829
+ if(p.trim().startsWith("max-age")) {
1830
+ let maxage = parseInt(p.trim().split("=")[1], 10);
1831
+ age = this.#syncedNowTimestampInSeconds + maxage; // convert to timestamp
1832
+ break; // break out of for
1833
+ }
1834
+ }
1835
+ }
1836
+
1837
+ if ("expires" in headers) {
1838
+ exp = Cache.parseToSeconds(headers.expires);
1839
+ }
1840
+
1841
+ // we will take the greater of max-age or expires, and if they are not 0 and not past, use it as expTimestamp
1842
+ let max = ( exp > age ) ? exp : age;
1843
+ if ( max !== 0 && expires > this.#syncedNowTimestampInSeconds) { expires = max; }
1844
+
1845
+ }
1846
+
1847
+ /* Write to Cache
1848
+ We are now ready to write to the cache
1849
+ */
1850
+ try {
1851
+ this.#store = CacheData.write(this.#idHash, this.#syncedNowTimestampInSeconds, body, headersForCache, this.#hostId, this.#pathId, expires, statusCode, this.#encrypt);
1852
+
1853
+ if (status === null) {
1854
+ if (prev.empty) {
1855
+ status = Cache.STATUS_NO_CACHE;
1856
+ } else if (this.getETag() === prev.eTag || this.getLastModified() === prev.modified) {
1857
+ status = Cache.STATUS_CACHE_SAME;
1858
+ } else if (prev.expired) {
1859
+ status = Cache.STATUS_EXPIRED;
1860
+ } else {
1861
+ status = Cache.STATUS_FORCED;
1862
+ }
1863
+ }
1864
+
1865
+ this.#status = status;
1866
+
1867
+ tools.DebugAndLog.debug("Cache Updated "+this.getStatus()+": "+this.#idHash);
1868
+
1869
+ } catch (error) {
1870
+ tools.DebugAndLog.error(`Cannot copy cached data to local store for evaluation: ${this.#idHash} ${error.message}`, error.stack);
1871
+ if ( this.#store === null ) {
1872
+ this.#store = CacheData.format(this.#syncedLaterTimestampInSeconds);
1873
+ }
1874
+ this.#status = Cache.STATUS_CACHE_ERROR;
1875
+ };
1876
+
1877
+ return this.#store;
1878
+
1879
+ };
1880
+
1881
+ };
1882
+
1883
+ class CacheableDataAccess {
1884
+ constructor() { };
1885
+
1886
+ static #prevId = -1;
1887
+
1888
+ static #getNextId() {
1889
+ this.#prevId++;
1890
+ return ""+this.#prevId;
1891
+ };
1892
+
1893
+ static async prime() {
1894
+ return CacheData.prime();
1895
+ };
1896
+
1897
+ /**
1898
+ * Data access object that will evaluate the cache and make a request to
1899
+ * an endpoint to refresh.
1900
+ *
1901
+ * @example
1902
+ * cachePolicy = {
1903
+ * overrideOriginHeaderExpiration: true,
1904
+ * defaultExpirationInSeconds: 60,
1905
+ * expirationIsOnInterval: true,
1906
+ * headersToRetain: [],
1907
+ * host: vars.policy.host,
1908
+ * path: vars.policy.endpoint.path,
1909
+ * encrypt: true
1910
+ * }
1911
+ *
1912
+ * connection = {
1913
+ * method: vars.method,
1914
+ * protocol: vars.protocol,
1915
+ * host: vars.host,
1916
+ * path: vars.path,
1917
+ * parameters: vars.parameters,
1918
+ * headers: vars.requestHeaders,
1919
+ * options: {timeout: vars.timeout}
1920
+ * }
1921
+ *
1922
+ * @param {object} cachePolicy A cache policy object.
1923
+ * @param {boolean} cachePolicy.overrideOriginHeaderExpiration
1924
+ * @param {number} cachePolicy.defaultExpirationInSeconds
1925
+ * @param {boolean} cachePolicy.expirationIsOnInterval
1926
+ * @param {Array|string} cachePolicy.headersToRetain
1927
+ * @param {string} cachePolicy.hostId
1928
+ * @param {string} cachePolicy.pathId
1929
+ * @param {boolean} cachePolicy.encrypt
1930
+ * @param {object} apiCallFunction The function to call in order to make the request. This function can call ANY datasource (file, http endpoint, etc) as long as it returns a DAO object
1931
+ * @param {object} connection A connection object that specifies an id, location, and connectin details for the apiCallFunction to access data. If you have a Connection object pass conn.toObject()
1932
+ * @param {string} connection.method
1933
+ * @param {string} connection.protocol
1934
+ * @param {string} connection.host
1935
+ * @param {string} connection.path
1936
+ * @param {object} connection.parameters
1937
+ * @param {object} connection.headers
1938
+ * @param {string} connection.body For POST requests a body with data may be sent.
1939
+ * @param {object} connection.options
1940
+ * @param {number} connection.options.timeout Number in ms for request to time out
1941
+ * @param {object} data An object passed to the apiCallFunction as a parameter. Set to null if the apiCallFunction does not require a data param
1942
+ * @param {object} tags For logging. Do not include sensitive information.
1943
+ * @returns {Promise<Cache>} A Cache object with either cached or fresh data.
1944
+ */
1945
+ static async getData(cachePolicy, apiCallFunction, connection, data = null, tags = {} ) {
1946
+
1947
+ return new Promise(async (resolve, reject) => {
1948
+
1949
+ CacheData.prime(); // prime anything we'll need that may have changed since init, we'll await the result before read and write
1950
+
1951
+ /* tags and id have no bearing on the idHash, it is only for human readable logs */
1952
+ if ( !("path" in tags) ) { tags.path = [cachePolicy.hostId.replace(/^\/|\/$/g, ''), cachePolicy.pathId.replace(/^\/|\/$/g, '')]; } // we don't want extra / in the glue
1953
+ if ( !("id" in tags) ) { tags.id = this.#getNextId(); }
1954
+
1955
+ tags.path = Cache.multipartId(tags.path, "/");
1956
+ tags.id = Cache.multipartId(tags.id, "/");
1957
+
1958
+ const timer = new tools.Timer(`timerGetCacheableData_${tags.path}::${tags.id}`, true);
1959
+ const idToHash = { data: data, connection: connection, cachePolicy: cachePolicy };
1960
+ const cache = new Cache(idToHash, cachePolicy);
1961
+ const idHash = cache.getIdHash();
1962
+
1963
+ try {
1964
+
1965
+ await cache.read();
1966
+
1967
+ if ( cache.needsRefresh() ) {
1968
+
1969
+ tools.DebugAndLog.debug("Cache needs refresh.");
1970
+
1971
+ // add etag and last modified to connection
1972
+ if ( !("headers" in connection)) { connection.headers = {}; }
1973
+ if ( !("if-none-match" in connection.headers) && cache.getETag() !== null) {
1974
+ connection.headers['if-none-match'] = cache.getETag();
1975
+ }
1976
+ if (!("if-modified-since" in connection.headers) && cache.getLastModified() !== null) {
1977
+ connection.headers['if-modified-since'] = cache.getLastModified();
1978
+ }
1979
+
1980
+ // request data from original source
1981
+ let originalSource = await apiCallFunction(connection, data);
1982
+
1983
+ if ( originalSource.success ) {
1984
+
1985
+ try {
1986
+ // check header and status for 304 not modified
1987
+ if (originalSource.statusCode === 304) {
1988
+ tools.DebugAndLog.debug("Received 304 Not Modified. Extending cache");
1989
+ cache.extendExpires(Cache.STATUS_ORIGINAL_NOT_MODIFIED, 0, originalSource.statusCode);
1990
+ } else {
1991
+ let body = ( typeof originalSource.body !== "object" ) ? originalSource.body : JSON.stringify(originalSource.body);
1992
+ await CacheData.prime(); // can't proceed until we have the secrets
1993
+ cache.update(body, originalSource.headers, originalSource.statusCode);
1994
+ }
1995
+
1996
+ } catch (error) {
1997
+ tools.DebugAndLog.error(`Not successful in creating cache: ${idHash} (${tags.path}/${tags.id}) ${error.message}`, error.stack);
1998
+ }
1999
+
2000
+ } else {
2001
+
2002
+ tools.DebugAndLog.error(`${originalSource.statusCode} | Not successful in getting data from original source for cache. Extending cache expires. ${idHash} (${tags.path}/${tags.id})`, originalSource);
2003
+ cache.extendExpires(Cache.STATUS_ORIGINAL_ERROR, 0, originalSource.statusCode);
2004
+
2005
+ }
2006
+ }
2007
+
2008
+ timer.stop();
2009
+ tools.DebugAndLog.log(`${idHash} | ${tags.path} | ${cache.getStatus()} | ${timer.elapsed()}`, "CACHE");
2010
+ resolve(cache);
2011
+
2012
+ } catch (error) {
2013
+ timer.stop();
2014
+ tools.DebugAndLog.error(`Error while getting data: (${tags.path}/${tags.id}) ${error.message}`, error.stack);
2015
+ reject(cache);
2016
+ };
2017
+ });
2018
+ };
2019
+ };
2020
+
2021
+ module.exports = {
2022
+ Cache,
2023
+ CacheableDataAccess
2024
+ };