@modernman00/shared-js-lib 1.2.82 → 1.2.87
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/AddressAutocomplete.js +473 -0
- package/CardCanvasGenerator.js +367 -0
- package/OfflineQueue.js +278 -0
- package/OtpInputManager.js +429 -0
- package/PwaInstall.js +165 -0
- package/PwaManager.js +342 -0
- package/ShareManager.js +462 -0
- package/config/version.php +1 -1
- package/index.js +9 -1
- package/package.json +12 -3
- package/release-history.md +37 -0
- package/templates/sw.template.js +239 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CardCanvasGenerator.js
|
|
3
|
+
* High-resolution 1200x630 / 1200x675 HTML5 Canvas Social Card Generator.
|
|
4
|
+
* Part of @modernman00/shared-js-lib
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export class CardCanvasGenerator {
|
|
8
|
+
/**
|
|
9
|
+
* Default configuration options
|
|
10
|
+
*/
|
|
11
|
+
static get DEFAULT_OPTIONS() {
|
|
12
|
+
return {
|
|
13
|
+
width: 1200,
|
|
14
|
+
height: 675,
|
|
15
|
+
brandName: 'Platform',
|
|
16
|
+
brandBadge: 'DECISION INTELLIGENCE',
|
|
17
|
+
title: 'Strategic Analysis',
|
|
18
|
+
subtitle: '',
|
|
19
|
+
score: 85,
|
|
20
|
+
maxScore: 100,
|
|
21
|
+
scoreLabel: 'SCORE',
|
|
22
|
+
verdict: 'STRONG GO',
|
|
23
|
+
verdictColor: '#10b981', // emerald
|
|
24
|
+
primaryColor: '#10b981',
|
|
25
|
+
secondaryColor: '#3b82f6',
|
|
26
|
+
accentColor: '#6366f1',
|
|
27
|
+
backgroundColor: '#090d16',
|
|
28
|
+
cardBackground: 'rgba(255, 255, 255, 0.03)',
|
|
29
|
+
borderColor: 'rgba(255, 255, 255, 0.1)',
|
|
30
|
+
textColor: '#ffffff',
|
|
31
|
+
mutedTextColor: '#94a3b8',
|
|
32
|
+
watermarkText: 'Generated with Decision Intelligence Platform',
|
|
33
|
+
metrics: [], // Array of { label: string, score: number, maxScore?: number }
|
|
34
|
+
showGauge: true,
|
|
35
|
+
showMetrics: true
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Renders the card onto a target HTML5 canvas element
|
|
41
|
+
* @param {HTMLCanvasElement} canvas
|
|
42
|
+
* @param {Object} options
|
|
43
|
+
* @returns {HTMLCanvasElement}
|
|
44
|
+
*/
|
|
45
|
+
static renderToCanvas(canvas, options = {}) {
|
|
46
|
+
const opts = { ...this.DEFAULT_OPTIONS, ...options };
|
|
47
|
+
const width = opts.width;
|
|
48
|
+
const height = opts.height;
|
|
49
|
+
|
|
50
|
+
canvas.width = width;
|
|
51
|
+
canvas.height = height;
|
|
52
|
+
const ctx = canvas.getContext('2d');
|
|
53
|
+
if (!ctx) {
|
|
54
|
+
throw new Error('Unable to obtain 2D canvas rendering context.');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 1. Draw Deep Gradient Background
|
|
58
|
+
const bgGrad = ctx.createLinearGradient(0, 0, width, height);
|
|
59
|
+
bgGrad.addColorStop(0, opts.backgroundColor || '#090d16');
|
|
60
|
+
bgGrad.addColorStop(0.5, '#0d1527');
|
|
61
|
+
bgGrad.addColorStop(1, '#050811');
|
|
62
|
+
ctx.fillStyle = bgGrad;
|
|
63
|
+
ctx.fillRect(0, 0, width, height);
|
|
64
|
+
|
|
65
|
+
// 2. Ambient Lighting / Glow Orbs
|
|
66
|
+
this._drawGlowOrb(ctx, 150, 120, 250, opts.primaryColor, 0.18);
|
|
67
|
+
this._drawGlowOrb(ctx, width - 200, height - 150, 300, opts.secondaryColor, 0.15);
|
|
68
|
+
|
|
69
|
+
// 3. Subtle Grid Pattern
|
|
70
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.025)';
|
|
71
|
+
ctx.lineWidth = 1;
|
|
72
|
+
for (let x = 40; x < width; x += 60) {
|
|
73
|
+
ctx.beginPath();
|
|
74
|
+
ctx.moveTo(x, 0);
|
|
75
|
+
ctx.lineTo(x, height);
|
|
76
|
+
ctx.stroke();
|
|
77
|
+
}
|
|
78
|
+
for (let y = 40; y < height; y += 60) {
|
|
79
|
+
ctx.beginPath();
|
|
80
|
+
ctx.moveTo(0, y);
|
|
81
|
+
ctx.lineTo(width, y);
|
|
82
|
+
ctx.stroke();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 4. Main Glassmorphic Card Container
|
|
86
|
+
const margin = 40;
|
|
87
|
+
const cardW = width - margin * 2;
|
|
88
|
+
const cardH = height - margin * 2;
|
|
89
|
+
this._drawRoundedRect(ctx, margin, margin, cardW, cardH, 24);
|
|
90
|
+
ctx.fillStyle = opts.cardBackground;
|
|
91
|
+
ctx.fill();
|
|
92
|
+
ctx.strokeStyle = opts.borderColor;
|
|
93
|
+
ctx.lineWidth = 1.5;
|
|
94
|
+
ctx.stroke();
|
|
95
|
+
|
|
96
|
+
// 5. Header: Brand & Badge
|
|
97
|
+
ctx.font = 'bold 26px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
98
|
+
ctx.fillStyle = opts.textColor;
|
|
99
|
+
ctx.textAlign = 'left';
|
|
100
|
+
ctx.textBaseline = 'middle';
|
|
101
|
+
ctx.fillText(opts.brandName, margin + 40, margin + 50);
|
|
102
|
+
|
|
103
|
+
if (opts.brandBadge) {
|
|
104
|
+
const brandWidth = ctx.measureText(opts.brandName).width;
|
|
105
|
+
const badgeX = margin + 40 + brandWidth + 16;
|
|
106
|
+
const badgeY = margin + 37;
|
|
107
|
+
const badgeText = String(opts.brandBadge).toUpperCase();
|
|
108
|
+
ctx.font = 'bold 12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
109
|
+
const badgeW = ctx.measureText(badgeText).width + 24;
|
|
110
|
+
|
|
111
|
+
this._drawRoundedRect(ctx, badgeX, badgeY, badgeW, 26, 6);
|
|
112
|
+
ctx.fillStyle = 'rgba(16, 185, 129, 0.15)';
|
|
113
|
+
ctx.fill();
|
|
114
|
+
ctx.strokeStyle = 'rgba(16, 185, 129, 0.4)';
|
|
115
|
+
ctx.lineWidth = 1;
|
|
116
|
+
ctx.stroke();
|
|
117
|
+
|
|
118
|
+
ctx.fillStyle = opts.primaryColor;
|
|
119
|
+
ctx.fillText(badgeText, badgeX + 12, badgeY + 13);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Header Divider Line
|
|
123
|
+
ctx.beginPath();
|
|
124
|
+
ctx.moveTo(margin + 40, margin + 85);
|
|
125
|
+
ctx.lineTo(margin + cardW - 40, margin + 85);
|
|
126
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
|
|
127
|
+
ctx.lineWidth = 1;
|
|
128
|
+
ctx.stroke();
|
|
129
|
+
|
|
130
|
+
// 6. Left Column: Circular Glowing Gauge (if enabled)
|
|
131
|
+
const hasGauge = Boolean(opts.showGauge);
|
|
132
|
+
const leftColWidth = hasGauge ? 340 : 0;
|
|
133
|
+
const contentStartX = margin + 40 + leftColWidth;
|
|
134
|
+
|
|
135
|
+
if (hasGauge) {
|
|
136
|
+
const gaugeCenterX = margin + 170;
|
|
137
|
+
const gaugeCenterY = margin + 280;
|
|
138
|
+
const gaugeRadius = 110;
|
|
139
|
+
|
|
140
|
+
// Outer track
|
|
141
|
+
ctx.beginPath();
|
|
142
|
+
ctx.arc(gaugeCenterX, gaugeCenterY, gaugeRadius, 0, Math.PI * 2);
|
|
143
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
|
|
144
|
+
ctx.lineWidth = 16;
|
|
145
|
+
ctx.lineCap = 'round';
|
|
146
|
+
ctx.stroke();
|
|
147
|
+
|
|
148
|
+
// Active Arc
|
|
149
|
+
const scorePct = Math.max(0, Math.min(100, opts.score)) / (opts.maxScore || 100);
|
|
150
|
+
const startAngle = -Math.PI / 2;
|
|
151
|
+
const endAngle = startAngle + scorePct * (Math.PI * 2);
|
|
152
|
+
|
|
153
|
+
ctx.beginPath();
|
|
154
|
+
ctx.arc(gaugeCenterX, gaugeCenterY, gaugeRadius, startAngle, endAngle);
|
|
155
|
+
const gaugeGrad = ctx.createLinearGradient(
|
|
156
|
+
gaugeCenterX - gaugeRadius,
|
|
157
|
+
gaugeCenterY - gaugeRadius,
|
|
158
|
+
gaugeCenterX + gaugeRadius,
|
|
159
|
+
gaugeCenterY + gaugeRadius
|
|
160
|
+
);
|
|
161
|
+
gaugeGrad.addColorStop(0, opts.primaryColor);
|
|
162
|
+
gaugeGrad.addColorStop(1, opts.secondaryColor);
|
|
163
|
+
ctx.strokeStyle = gaugeGrad;
|
|
164
|
+
ctx.lineWidth = 16;
|
|
165
|
+
ctx.stroke();
|
|
166
|
+
|
|
167
|
+
// Score Value
|
|
168
|
+
ctx.textAlign = 'center';
|
|
169
|
+
ctx.textBaseline = 'middle';
|
|
170
|
+
ctx.font = 'bold 64px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
171
|
+
ctx.fillStyle = opts.textColor;
|
|
172
|
+
ctx.fillText(`${Math.round(opts.score)}%`, gaugeCenterX, gaugeCenterY - 10);
|
|
173
|
+
|
|
174
|
+
ctx.font = '600 14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
175
|
+
ctx.fillStyle = opts.mutedTextColor;
|
|
176
|
+
ctx.fillText(opts.scoreLabel, gaugeCenterX, gaugeCenterY + 36);
|
|
177
|
+
|
|
178
|
+
// Verdict Pill below gauge
|
|
179
|
+
if (opts.verdict) {
|
|
180
|
+
const pillY = gaugeCenterY + gaugeRadius + 30;
|
|
181
|
+
ctx.font = 'bold 15px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
182
|
+
const pillText = String(opts.verdict).toUpperCase();
|
|
183
|
+
const pillW = ctx.measureText(pillText).width + 36;
|
|
184
|
+
const pillH = 34;
|
|
185
|
+
|
|
186
|
+
this._drawRoundedRect(ctx, gaugeCenterX - pillW / 2, pillY, pillW, pillH, 17);
|
|
187
|
+
ctx.fillStyle = 'rgba(16, 185, 129, 0.15)';
|
|
188
|
+
ctx.fill();
|
|
189
|
+
ctx.strokeStyle = opts.verdictColor;
|
|
190
|
+
ctx.lineWidth = 1.5;
|
|
191
|
+
ctx.stroke();
|
|
192
|
+
|
|
193
|
+
ctx.fillStyle = opts.verdictColor;
|
|
194
|
+
ctx.fillText(pillText, gaugeCenterX, pillY + pillH / 2);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 7. Right Column: Headline, Subtitle & Metrics
|
|
199
|
+
ctx.textAlign = 'left';
|
|
200
|
+
ctx.textBaseline = 'top';
|
|
201
|
+
|
|
202
|
+
// Title (multi-line word wrap)
|
|
203
|
+
ctx.font = 'bold 38px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
204
|
+
ctx.fillStyle = opts.textColor;
|
|
205
|
+
const maxTitleW = cardW - (contentStartX - margin) - 40;
|
|
206
|
+
const titleLines = this._wrapText(ctx, opts.title, maxTitleW);
|
|
207
|
+
let curY = margin + 120;
|
|
208
|
+
|
|
209
|
+
titleLines.slice(0, 2).forEach((line) => {
|
|
210
|
+
ctx.fillText(line, contentStartX, curY);
|
|
211
|
+
curY += 48;
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Subtitle
|
|
215
|
+
if (opts.subtitle) {
|
|
216
|
+
curY += 6;
|
|
217
|
+
ctx.font = '500 18px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
218
|
+
ctx.fillStyle = opts.mutedTextColor;
|
|
219
|
+
const subLines = this._wrapText(ctx, opts.subtitle, maxTitleW);
|
|
220
|
+
subLines.slice(0, 2).forEach((line) => {
|
|
221
|
+
ctx.fillText(line, contentStartX, curY);
|
|
222
|
+
curY += 26;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Metrics Breakdown Bars (if provided)
|
|
227
|
+
if (opts.showMetrics && Array.isArray(opts.metrics) && opts.metrics.length > 0) {
|
|
228
|
+
curY += 24;
|
|
229
|
+
const barTotalWidth = Math.min(maxTitleW, 640);
|
|
230
|
+
const metricsToShow = opts.metrics.slice(0, 4);
|
|
231
|
+
|
|
232
|
+
metricsToShow.forEach((metric) => {
|
|
233
|
+
const mScore = Number(metric.score ?? 0);
|
|
234
|
+
const mMax = Number(metric.maxScore ?? 10);
|
|
235
|
+
const mRatio = Math.max(0, Math.min(1, mMax > 0 ? mScore / mMax : 0));
|
|
236
|
+
|
|
237
|
+
// Metric label & score
|
|
238
|
+
ctx.font = '600 15px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
239
|
+
ctx.fillStyle = '#e2e8f0';
|
|
240
|
+
ctx.fillText(metric.label, contentStartX, curY);
|
|
241
|
+
|
|
242
|
+
ctx.textAlign = 'right';
|
|
243
|
+
ctx.fillStyle = opts.primaryColor;
|
|
244
|
+
ctx.fillText(`${mScore} / ${mMax}`, contentStartX + barTotalWidth, curY);
|
|
245
|
+
ctx.textAlign = 'left';
|
|
246
|
+
|
|
247
|
+
// Track bar
|
|
248
|
+
const barY = curY + 24;
|
|
249
|
+
const barH = 8;
|
|
250
|
+
this._drawRoundedRect(ctx, contentStartX, barY, barTotalWidth, barH, 4);
|
|
251
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.08)';
|
|
252
|
+
ctx.fill();
|
|
253
|
+
|
|
254
|
+
// Fill bar
|
|
255
|
+
if (mRatio > 0) {
|
|
256
|
+
const fillW = Math.max(8, barTotalWidth * mRatio);
|
|
257
|
+
this._drawRoundedRect(ctx, contentStartX, barY, fillW, barH, 4);
|
|
258
|
+
const barGrad = ctx.createLinearGradient(contentStartX, barY, contentStartX + fillW, barY);
|
|
259
|
+
barGrad.addColorStop(0, opts.primaryColor);
|
|
260
|
+
barGrad.addColorStop(1, opts.secondaryColor);
|
|
261
|
+
ctx.fillStyle = barGrad;
|
|
262
|
+
ctx.fill();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
curY += 46;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 8. Footer Watermark & Domain
|
|
270
|
+
const footerY = margin + cardH - 35;
|
|
271
|
+
ctx.font = '500 14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
272
|
+
ctx.fillStyle = opts.mutedTextColor;
|
|
273
|
+
ctx.textAlign = 'left';
|
|
274
|
+
ctx.textBaseline = 'middle';
|
|
275
|
+
ctx.fillText(opts.watermarkText, margin + 40, footerY);
|
|
276
|
+
|
|
277
|
+
return canvas;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Generates a PNG Blob from options
|
|
282
|
+
* @param {Object} options
|
|
283
|
+
* @param {string} [type='image/png']
|
|
284
|
+
* @param {number} [quality=0.95]
|
|
285
|
+
* @returns {Promise<Blob>}
|
|
286
|
+
*/
|
|
287
|
+
static generateBlob(options = {}, type = 'image/png', quality = 0.95) {
|
|
288
|
+
return new Promise((resolve, reject) => {
|
|
289
|
+
try {
|
|
290
|
+
const canvas = document.createElement('canvas');
|
|
291
|
+
this.renderToCanvas(canvas, options);
|
|
292
|
+
canvas.toBlob((blob) => {
|
|
293
|
+
if (blob) {
|
|
294
|
+
resolve(blob);
|
|
295
|
+
} else {
|
|
296
|
+
reject(new Error('Canvas toBlob returned null'));
|
|
297
|
+
}
|
|
298
|
+
}, type, quality);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
reject(err);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Generates a Data URL string from options
|
|
307
|
+
* @param {Object} options
|
|
308
|
+
* @param {string} [type='image/png']
|
|
309
|
+
* @returns {string}
|
|
310
|
+
*/
|
|
311
|
+
static generateDataUrl(options = {}, type = 'image/png') {
|
|
312
|
+
const canvas = document.createElement('canvas');
|
|
313
|
+
this.renderToCanvas(canvas, options);
|
|
314
|
+
return canvas.toDataURL(type);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// --- Internal Drawing Helpers ---
|
|
318
|
+
|
|
319
|
+
static _drawGlowOrb(ctx, x, y, radius, color, alpha) {
|
|
320
|
+
ctx.save();
|
|
321
|
+
ctx.beginPath();
|
|
322
|
+
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
323
|
+
const grad = ctx.createRadialGradient(x, y, 0, x, y, radius);
|
|
324
|
+
grad.addColorStop(0, color);
|
|
325
|
+
grad.addColorStop(1, 'transparent');
|
|
326
|
+
ctx.fillStyle = grad;
|
|
327
|
+
ctx.globalAlpha = alpha;
|
|
328
|
+
ctx.fill();
|
|
329
|
+
ctx.restore();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
static _drawRoundedRect(ctx, x, y, width, height, radius) {
|
|
333
|
+
ctx.beginPath();
|
|
334
|
+
ctx.moveTo(x + radius, y);
|
|
335
|
+
ctx.lineTo(x + width - radius, y);
|
|
336
|
+
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
|
337
|
+
ctx.lineTo(x + width, y + height - radius);
|
|
338
|
+
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
|
339
|
+
ctx.lineTo(x + radius, y + height);
|
|
340
|
+
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
|
341
|
+
ctx.lineTo(x, y + radius);
|
|
342
|
+
ctx.quadraticCurveTo(x, y, x + radius, y);
|
|
343
|
+
ctx.closePath();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
static _wrapText(ctx, text, maxWidth) {
|
|
347
|
+
if (!text) return [];
|
|
348
|
+
const words = String(text).split(' ');
|
|
349
|
+
const lines = [];
|
|
350
|
+
let currentLine = words[0] || '';
|
|
351
|
+
|
|
352
|
+
for (let i = 1; i < words.length; i++) {
|
|
353
|
+
const word = words[i];
|
|
354
|
+
const width = ctx.measureText(`${currentLine} ${word}`).width;
|
|
355
|
+
if (width < maxWidth) {
|
|
356
|
+
currentLine += ` ${word}`;
|
|
357
|
+
} else {
|
|
358
|
+
lines.push(currentLine);
|
|
359
|
+
currentLine = word;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (currentLine) {
|
|
363
|
+
lines.push(currentLine);
|
|
364
|
+
}
|
|
365
|
+
return lines;
|
|
366
|
+
}
|
|
367
|
+
}
|
package/OfflineQueue.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 📦 OfflineQueue — Resilient IndexedDB Offline Request Queue
|
|
3
|
+
* Part of @modernman00/shared-js-lib
|
|
4
|
+
*
|
|
5
|
+
* Implements the Deloitte Digital & BRATS-approved enterprise architecture:
|
|
6
|
+
* 1. UUIDv4 Idempotency Key injection ('X-Idempotency-Key') to eliminate duplicate writes
|
|
7
|
+
* 2. Strict Security Blacklist: Refuses to store credentials, passwords, or /auth/* /admin/* requests
|
|
8
|
+
* 3. Kieran Memory Cap: 2MB max per payload, 30 items max queue depth
|
|
9
|
+
* 4. 24-Hour automatic request expiration
|
|
10
|
+
* 5. Automatic replay on 'online' event with fresh CSRF token header
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export class OfflineQueue {
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.appName = options.appName || (typeof window !== 'undefined' ? window.location.hostname.replace(/[^a-zA-Z0-9]/g, '_') : 'app');
|
|
16
|
+
this.dbName = options.dbName || `${this.appName}_offline_db`;
|
|
17
|
+
this.storeName = 'offline_requests';
|
|
18
|
+
this.dbVersion = 1;
|
|
19
|
+
this.maxItems = options.maxItems || 30;
|
|
20
|
+
this.maxPayloadBytes = options.maxPayloadBytes || 2 * 1024 * 1024; // 2MB cap
|
|
21
|
+
this.maxAgeMs = options.maxAgeMs || 24 * 60 * 60 * 1000; // 24 hours
|
|
22
|
+
this.db = null;
|
|
23
|
+
this.isReplaying = false;
|
|
24
|
+
|
|
25
|
+
// Security Blacklist (never cache credentials/auth/passwords offline)
|
|
26
|
+
this.blacklistPatterns = [
|
|
27
|
+
/\/auth\//i,
|
|
28
|
+
/\/admin\//i,
|
|
29
|
+
/\/login/i,
|
|
30
|
+
/\/logout/i,
|
|
31
|
+
/\/signout/i,
|
|
32
|
+
/\/password/i,
|
|
33
|
+
/\/2fa/i,
|
|
34
|
+
/\/totp/i
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
if (typeof window !== 'undefined') {
|
|
38
|
+
window.addEventListener('online', () => this.replay());
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Generates standard RFC4122 v4 UUID for request idempotency
|
|
44
|
+
*/
|
|
45
|
+
static generateUUID() {
|
|
46
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
47
|
+
return crypto.randomUUID();
|
|
48
|
+
}
|
|
49
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
50
|
+
const r = (Math.random() * 16) | 0;
|
|
51
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
52
|
+
return v.toString(16);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Initializes or returns the open IndexedDB instance
|
|
58
|
+
*/
|
|
59
|
+
async getDB() {
|
|
60
|
+
if (this.db) return this.db;
|
|
61
|
+
if (typeof indexedDB === 'undefined') {
|
|
62
|
+
throw new Error('IndexedDB is not supported in this browser environment');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const request = indexedDB.open(this.dbName, this.dbVersion);
|
|
67
|
+
|
|
68
|
+
request.onerror = () => reject(request.error);
|
|
69
|
+
request.onsuccess = () => {
|
|
70
|
+
this.db = request.result;
|
|
71
|
+
resolve(this.db);
|
|
72
|
+
};
|
|
73
|
+
request.onupgradeneeded = (event) => {
|
|
74
|
+
const db = event.target.result;
|
|
75
|
+
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
76
|
+
const store = db.createObjectStore(this.storeName, { keyPath: 'id', autoIncrement: true });
|
|
77
|
+
store.createIndex('timestamp', 'timestamp', { unique: false });
|
|
78
|
+
store.createIndex('idempotencyKey', 'idempotencyKey', { unique: true });
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if URL matches the security blacklist
|
|
86
|
+
*/
|
|
87
|
+
isBlacklisted(url) {
|
|
88
|
+
if (!url || typeof url !== 'string') return true;
|
|
89
|
+
return this.blacklistPatterns.some(pattern => pattern.test(url));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Enqueues an outgoing request for offline storage and later replay
|
|
94
|
+
*/
|
|
95
|
+
async enqueue(requestData) {
|
|
96
|
+
const { url, method = 'POST', headers = {}, body = null } = requestData;
|
|
97
|
+
|
|
98
|
+
// Security Gate
|
|
99
|
+
if (this.isBlacklisted(url)) {
|
|
100
|
+
console.warn(`[OfflineQueue] Blocked security-sensitive request from offline storage: ${url}`);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Payload Size Guard (Kieran Memory Protection)
|
|
105
|
+
const serializedBody = typeof body === 'string' ? body : JSON.stringify(body || {});
|
|
106
|
+
const bodySize = typeof Blob !== 'undefined' ? new Blob([serializedBody]).size : serializedBody.length;
|
|
107
|
+
if (bodySize > this.maxPayloadBytes) {
|
|
108
|
+
console.warn(`[OfflineQueue] Request payload exceeds 2MB memory cap (${bodySize} bytes). Dropping.`);
|
|
109
|
+
if (typeof window !== 'undefined' && window.dispatchEvent) {
|
|
110
|
+
window.dispatchEvent(new CustomEvent('offlinequeue:payload-too-large', { detail: { url } }));
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Filter headers to strip sensitive authorization tokens from plaintext IndexedDB
|
|
116
|
+
const safeHeaders = {};
|
|
117
|
+
const headerEntries = headers.entries ? Array.from(headers.entries()) : Object.entries(headers || {});
|
|
118
|
+
for (const [key, value] of headerEntries) {
|
|
119
|
+
const lower = key.toLowerCase();
|
|
120
|
+
if (lower !== 'authorization' && lower !== 'cookie' && lower !== 'set-cookie') {
|
|
121
|
+
safeHeaders[key] = value;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const idempotencyKey = safeHeaders['x-idempotency-key'] || safeHeaders['X-Idempotency-Key'] || OfflineQueue.generateUUID();
|
|
126
|
+
safeHeaders['X-Idempotency-Key'] = idempotencyKey;
|
|
127
|
+
|
|
128
|
+
const db = await this.getDB();
|
|
129
|
+
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const tx = db.transaction(this.storeName, 'readwrite');
|
|
132
|
+
const store = tx.objectStore(this.storeName);
|
|
133
|
+
|
|
134
|
+
// Check max queue length
|
|
135
|
+
const countReq = store.count();
|
|
136
|
+
countReq.onsuccess = () => {
|
|
137
|
+
if (countReq.result >= this.maxItems) {
|
|
138
|
+
console.warn(`[OfflineQueue] Queue reached max depth (${this.maxItems}). Dropping oldest request.`);
|
|
139
|
+
const openCursor = store.openCursor();
|
|
140
|
+
openCursor.onsuccess = (e) => {
|
|
141
|
+
const cursor = e.target.result;
|
|
142
|
+
if (cursor) {
|
|
143
|
+
store.delete(cursor.primaryKey);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const item = {
|
|
149
|
+
url,
|
|
150
|
+
method: method.toUpperCase(),
|
|
151
|
+
headers: safeHeaders,
|
|
152
|
+
body: serializedBody,
|
|
153
|
+
idempotencyKey,
|
|
154
|
+
timestamp: Date.now()
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
store.add(item);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
tx.oncomplete = () => {
|
|
161
|
+
console.log(`[OfflineQueue] Request queued successfully with Idempotency Key: ${idempotencyKey}`);
|
|
162
|
+
if (typeof window !== 'undefined' && window.dispatchEvent) {
|
|
163
|
+
window.dispatchEvent(new CustomEvent('offlinequeue:enqueued', { detail: { url, idempotencyKey } }));
|
|
164
|
+
}
|
|
165
|
+
resolve(idempotencyKey);
|
|
166
|
+
};
|
|
167
|
+
tx.onerror = () => reject(tx.error);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Replays all stored offline requests sequentially
|
|
173
|
+
*/
|
|
174
|
+
async replay() {
|
|
175
|
+
if (this.isReplaying || (typeof navigator !== 'undefined' && !navigator.onLine)) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
this.isReplaying = true;
|
|
180
|
+
const db = await this.getDB();
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const items = await new Promise((resolve, reject) => {
|
|
184
|
+
const tx = db.transaction(this.storeName, 'readonly');
|
|
185
|
+
const store = tx.objectStore(this.storeName);
|
|
186
|
+
const req = store.getAll();
|
|
187
|
+
req.onsuccess = () => resolve(req.result || []);
|
|
188
|
+
req.onerror = () => reject(req.error);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (items.length === 0) {
|
|
192
|
+
this.isReplaying = false;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
console.log(`[OfflineQueue] Network restored. Replaying ${items.length} queued request(s)...`);
|
|
197
|
+
|
|
198
|
+
// Refresh CSRF Token if available in DOM
|
|
199
|
+
let currentCsrf = null;
|
|
200
|
+
if (typeof document !== 'undefined') {
|
|
201
|
+
const metaCsrf = document.querySelector('meta[name="csrf-token"]') || document.querySelector('meta[name="csrf"]');
|
|
202
|
+
if (metaCsrf) currentCsrf = metaCsrf.getAttribute('content');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const now = Date.now();
|
|
206
|
+
|
|
207
|
+
for (const item of items) {
|
|
208
|
+
// Expiration check (24h)
|
|
209
|
+
if (now - item.timestamp > this.maxAgeMs) {
|
|
210
|
+
console.warn(`[OfflineQueue] Request to ${item.url} expired (>24h). Removing from queue.`);
|
|
211
|
+
await this.deleteItem(item.id);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const headers = { ...item.headers };
|
|
216
|
+
if (currentCsrf) {
|
|
217
|
+
headers['X-CSRF-TOKEN'] = currentCsrf;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const response = await fetch(item.url, {
|
|
222
|
+
method: item.method,
|
|
223
|
+
headers,
|
|
224
|
+
body: item.body
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// If server successfully processed (2xx or 4xx business error), delete from queue
|
|
228
|
+
// (4xx means invalid input or already processed, no point in infinite retry loop)
|
|
229
|
+
if (response.ok || (response.status >= 400 && response.status < 500)) {
|
|
230
|
+
console.log(`[OfflineQueue] Replayed ${item.url} -> Status ${response.status}`);
|
|
231
|
+
await this.deleteItem(item.id);
|
|
232
|
+
} else {
|
|
233
|
+
console.warn(`[OfflineQueue] Server returned ${response.status} for ${item.url}. Will retry later.`);
|
|
234
|
+
break; // Stop replay if server is returning 5xx errors
|
|
235
|
+
}
|
|
236
|
+
} catch (networkErr) {
|
|
237
|
+
console.warn(`[OfflineQueue] Network still unstable during replay: ${networkErr.message}`);
|
|
238
|
+
break; // Stop loop and wait for next online event
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} catch (err) {
|
|
242
|
+
console.error('[OfflineQueue] Replay cycle error:', err);
|
|
243
|
+
} finally {
|
|
244
|
+
this.isReplaying = false;
|
|
245
|
+
if (typeof window !== 'undefined' && window.dispatchEvent) {
|
|
246
|
+
window.dispatchEvent(new CustomEvent('offlinequeue:replay-complete'));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Deletes a single item by primary key ID
|
|
253
|
+
*/
|
|
254
|
+
async deleteItem(id) {
|
|
255
|
+
const db = await this.getDB();
|
|
256
|
+
return new Promise((resolve, reject) => {
|
|
257
|
+
const tx = db.transaction(this.storeName, 'readwrite');
|
|
258
|
+
const store = tx.objectStore(this.storeName);
|
|
259
|
+
store.delete(id);
|
|
260
|
+
tx.oncomplete = () => resolve();
|
|
261
|
+
tx.onerror = () => reject(tx.error);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Clears all queued items
|
|
267
|
+
*/
|
|
268
|
+
async clear() {
|
|
269
|
+
const db = await this.getDB();
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
const tx = db.transaction(this.storeName, 'readwrite');
|
|
272
|
+
const store = tx.objectStore(this.storeName);
|
|
273
|
+
store.clear();
|
|
274
|
+
tx.oncomplete = () => resolve();
|
|
275
|
+
tx.onerror = () => reject(tx.error);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|