secure-ui-components 0.2.2 → 0.2.4
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/dist/components/secure-card/secure-card.js +1 -766
- package/dist/components/secure-datetime/secure-datetime.js +1 -570
- package/dist/components/secure-file-upload/secure-file-upload.js +1 -868
- package/dist/components/secure-form/secure-form.js +1 -797
- package/dist/components/secure-input/secure-input.css +67 -1
- package/dist/components/secure-input/secure-input.d.ts +14 -0
- package/dist/components/secure-input/secure-input.d.ts.map +1 -1
- package/dist/components/secure-input/secure-input.js +1 -805
- package/dist/components/secure-input/secure-input.js.map +1 -1
- package/dist/components/secure-password-confirm/secure-password-confirm.js +1 -329
- package/dist/components/secure-select/secure-select.js +1 -589
- package/dist/components/secure-submit-button/secure-submit-button.js +1 -378
- package/dist/components/secure-table/secure-table.js +33 -528
- package/dist/components/secure-telemetry-provider/secure-telemetry-provider.js +1 -201
- package/dist/components/secure-textarea/secure-textarea.css +66 -1
- package/dist/components/secure-textarea/secure-textarea.d.ts +11 -0
- package/dist/components/secure-textarea/secure-textarea.d.ts.map +1 -1
- package/dist/components/secure-textarea/secure-textarea.js +1 -436
- package/dist/components/secure-textarea/secure-textarea.js.map +1 -1
- package/dist/core/base-component.d.ts +18 -0
- package/dist/core/base-component.d.ts.map +1 -1
- package/dist/core/base-component.js +1 -455
- package/dist/core/base-component.js.map +1 -1
- package/dist/core/security-config.js +1 -242
- package/dist/core/types.js +0 -2
- package/dist/index.js +1 -17
- package/dist/package.json +4 -2
- package/package.json +4 -2
|
@@ -30,871 +30,4 @@
|
|
|
30
30
|
*
|
|
31
31
|
* @module secure-file-upload
|
|
32
32
|
* @license MIT
|
|
33
|
-
*/
|
|
34
|
-
import { SecureBaseComponent } from '../../core/base-component.js';
|
|
35
|
-
import { SecurityTier } from '../../core/security-config.js';
|
|
36
|
-
/**
|
|
37
|
-
* Secure File Upload Web Component
|
|
38
|
-
*
|
|
39
|
-
* Provides a security-hardened file upload field with progressive enhancement.
|
|
40
|
-
* The component works as a standard file input without JavaScript and
|
|
41
|
-
* enhances with security features when JavaScript is available.
|
|
42
|
-
*
|
|
43
|
-
* @extends SecureBaseComponent
|
|
44
|
-
*/
|
|
45
|
-
export class SecureFileUpload extends SecureBaseComponent {
|
|
46
|
-
/**
|
|
47
|
-
* File input element reference
|
|
48
|
-
* @private
|
|
49
|
-
*/
|
|
50
|
-
#fileInput = null;
|
|
51
|
-
/**
|
|
52
|
-
* Label element reference
|
|
53
|
-
* @private
|
|
54
|
-
*/
|
|
55
|
-
#labelElement = null;
|
|
56
|
-
/**
|
|
57
|
-
* Error container element reference
|
|
58
|
-
* @private
|
|
59
|
-
*/
|
|
60
|
-
#errorContainer = null;
|
|
61
|
-
/**
|
|
62
|
-
* File preview container
|
|
63
|
-
* @private
|
|
64
|
-
*/
|
|
65
|
-
#previewContainer = null;
|
|
66
|
-
/**
|
|
67
|
-
* Drop zone element
|
|
68
|
-
* @private
|
|
69
|
-
*/
|
|
70
|
-
#dropZone = null;
|
|
71
|
-
/**
|
|
72
|
-
* File name display element
|
|
73
|
-
* @private
|
|
74
|
-
*/
|
|
75
|
-
#fileNameDisplay = null;
|
|
76
|
-
/**
|
|
77
|
-
* Selected files
|
|
78
|
-
* @private
|
|
79
|
-
*/
|
|
80
|
-
#selectedFiles = null;
|
|
81
|
-
/**
|
|
82
|
-
* Unique ID for this file upload instance
|
|
83
|
-
* @private
|
|
84
|
-
*/
|
|
85
|
-
#instanceId = `secure-file-upload-${Math.random().toString(36).substring(2, 11)}`;
|
|
86
|
-
/**
|
|
87
|
-
* Allowed MIME types
|
|
88
|
-
* @private
|
|
89
|
-
*/
|
|
90
|
-
#allowedTypes = new Set();
|
|
91
|
-
/**
|
|
92
|
-
* Maximum file size in bytes
|
|
93
|
-
* @private
|
|
94
|
-
*/
|
|
95
|
-
#maxSize = 5 * 1024 * 1024; // Default 5MB
|
|
96
|
-
/**
|
|
97
|
-
* Optional scan hook for server-side file scanning (e.g. malware detection)
|
|
98
|
-
* @private
|
|
99
|
-
*/
|
|
100
|
-
#scanHook = null;
|
|
101
|
-
/**
|
|
102
|
-
* Whether a scan is currently in progress
|
|
103
|
-
* @private
|
|
104
|
-
*/
|
|
105
|
-
#scanning = false;
|
|
106
|
-
/**
|
|
107
|
-
* Observed attributes for this component
|
|
108
|
-
*
|
|
109
|
-
* @static
|
|
110
|
-
*/
|
|
111
|
-
static get observedAttributes() {
|
|
112
|
-
return [
|
|
113
|
-
...super.observedAttributes,
|
|
114
|
-
'name',
|
|
115
|
-
'label',
|
|
116
|
-
'accept',
|
|
117
|
-
'max-size',
|
|
118
|
-
'multiple',
|
|
119
|
-
'required',
|
|
120
|
-
'capture'
|
|
121
|
-
];
|
|
122
|
-
}
|
|
123
|
-
/**
|
|
124
|
-
* Constructor
|
|
125
|
-
*/
|
|
126
|
-
constructor() {
|
|
127
|
-
super();
|
|
128
|
-
}
|
|
129
|
-
/**
|
|
130
|
-
* Render the file upload component
|
|
131
|
-
*
|
|
132
|
-
* Security Note: We use a native <input type="file"> element wrapped in our
|
|
133
|
-
* web component to ensure progressive enhancement. The native input works
|
|
134
|
-
* without JavaScript, and we enhance it with security features when JS is available.
|
|
135
|
-
*
|
|
136
|
-
* @protected
|
|
137
|
-
*/
|
|
138
|
-
render() {
|
|
139
|
-
const fragment = document.createDocumentFragment();
|
|
140
|
-
const container = document.createElement('div');
|
|
141
|
-
container.className = 'file-upload-container';
|
|
142
|
-
container.setAttribute('part', 'container');
|
|
143
|
-
// Create label
|
|
144
|
-
const label = this.getAttribute('label');
|
|
145
|
-
if (label) {
|
|
146
|
-
this.#labelElement = document.createElement('label');
|
|
147
|
-
this.#labelElement.htmlFor = this.#instanceId;
|
|
148
|
-
this.#labelElement.textContent = this.sanitizeValue(label);
|
|
149
|
-
this.#labelElement.setAttribute('part', 'label');
|
|
150
|
-
container.appendChild(this.#labelElement);
|
|
151
|
-
}
|
|
152
|
-
// Create drop zone
|
|
153
|
-
this.#dropZone = document.createElement('div');
|
|
154
|
-
this.#dropZone.className = 'drop-zone';
|
|
155
|
-
this.#dropZone.setAttribute('part', 'wrapper');
|
|
156
|
-
// Create the file input element
|
|
157
|
-
this.#fileInput = document.createElement('input');
|
|
158
|
-
this.#fileInput.type = 'file';
|
|
159
|
-
this.#fileInput.id = this.#instanceId;
|
|
160
|
-
this.#fileInput.className = 'file-input';
|
|
161
|
-
this.#fileInput.setAttribute('part', 'input');
|
|
162
|
-
// Apply attributes
|
|
163
|
-
this.#applyFileInputAttributes();
|
|
164
|
-
// Set up event listeners
|
|
165
|
-
this.#attachEventListeners();
|
|
166
|
-
// Create Bulma-style drop zone content
|
|
167
|
-
const dropZoneContent = document.createElement('div');
|
|
168
|
-
dropZoneContent.className = 'drop-zone-content has-name';
|
|
169
|
-
// Call-to-action button
|
|
170
|
-
const fileCta = document.createElement('span');
|
|
171
|
-
fileCta.className = 'file-cta';
|
|
172
|
-
const dropIcon = document.createElement('span');
|
|
173
|
-
dropIcon.className = 'drop-icon';
|
|
174
|
-
// Build SVG programmatically — never use innerHTML (CSP + XSS risk)
|
|
175
|
-
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
176
|
-
svg.setAttribute('width', '16');
|
|
177
|
-
svg.setAttribute('height', '16');
|
|
178
|
-
svg.setAttribute('viewBox', '0 0 24 24');
|
|
179
|
-
svg.setAttribute('fill', 'none');
|
|
180
|
-
svg.setAttribute('stroke', 'currentColor');
|
|
181
|
-
svg.setAttribute('stroke-width', '2');
|
|
182
|
-
svg.setAttribute('stroke-linecap', 'round');
|
|
183
|
-
svg.setAttribute('stroke-linejoin', 'round');
|
|
184
|
-
svg.setAttribute('aria-hidden', 'true');
|
|
185
|
-
svg.setAttribute('focusable', 'false');
|
|
186
|
-
const svgPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
187
|
-
svgPath.setAttribute('d', 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4');
|
|
188
|
-
const svgPolyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
|
|
189
|
-
svgPolyline.setAttribute('points', '17 8 12 3 7 8');
|
|
190
|
-
const svgLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
191
|
-
svgLine.setAttribute('x1', '12');
|
|
192
|
-
svgLine.setAttribute('y1', '3');
|
|
193
|
-
svgLine.setAttribute('x2', '12');
|
|
194
|
-
svgLine.setAttribute('y2', '15');
|
|
195
|
-
svg.appendChild(svgPath);
|
|
196
|
-
svg.appendChild(svgPolyline);
|
|
197
|
-
svg.appendChild(svgLine);
|
|
198
|
-
dropIcon.appendChild(svg);
|
|
199
|
-
fileCta.appendChild(dropIcon);
|
|
200
|
-
const dropText = document.createElement('span');
|
|
201
|
-
dropText.className = 'drop-text';
|
|
202
|
-
dropText.textContent = 'Choose a file\u2026';
|
|
203
|
-
fileCta.appendChild(dropText);
|
|
204
|
-
dropZoneContent.appendChild(fileCta);
|
|
205
|
-
// Filename display area
|
|
206
|
-
this.#fileNameDisplay = document.createElement('span');
|
|
207
|
-
this.#fileNameDisplay.className = 'file-name-display';
|
|
208
|
-
this.#fileNameDisplay.textContent = 'No file selected';
|
|
209
|
-
dropZoneContent.appendChild(this.#fileNameDisplay);
|
|
210
|
-
this.#dropZone.appendChild(this.#fileInput);
|
|
211
|
-
this.#dropZone.appendChild(dropZoneContent);
|
|
212
|
-
container.appendChild(this.#dropZone);
|
|
213
|
-
// Accepted types hint (below the input)
|
|
214
|
-
const dropHint = document.createElement('div');
|
|
215
|
-
dropHint.className = 'drop-hint';
|
|
216
|
-
dropHint.textContent = this.#getAcceptHint();
|
|
217
|
-
container.appendChild(dropHint);
|
|
218
|
-
// Create preview container
|
|
219
|
-
this.#previewContainer = document.createElement('div');
|
|
220
|
-
this.#previewContainer.className = 'preview-container';
|
|
221
|
-
container.appendChild(this.#previewContainer);
|
|
222
|
-
// Create error container
|
|
223
|
-
// role="alert" already implies aria-live="assertive" — do not override with polite
|
|
224
|
-
this.#errorContainer = document.createElement('div');
|
|
225
|
-
this.#errorContainer.className = 'error-container hidden';
|
|
226
|
-
this.#errorContainer.setAttribute('role', 'alert');
|
|
227
|
-
this.#errorContainer.setAttribute('part', 'error');
|
|
228
|
-
this.#errorContainer.id = `${this.#instanceId}-error`;
|
|
229
|
-
container.appendChild(this.#errorContainer);
|
|
230
|
-
// Link file input to its error container for screen readers
|
|
231
|
-
this.#fileInput.setAttribute('aria-describedby', `${this.#instanceId}-error`);
|
|
232
|
-
// Add component styles (CSP-compliant via adoptedStyleSheets)
|
|
233
|
-
this.addComponentStyles(this.#getComponentStyles());
|
|
234
|
-
fragment.appendChild(container);
|
|
235
|
-
return fragment;
|
|
236
|
-
}
|
|
237
|
-
/**
|
|
238
|
-
* Apply attributes to the file input
|
|
239
|
-
*
|
|
240
|
-
* @private
|
|
241
|
-
*/
|
|
242
|
-
#applyFileInputAttributes() {
|
|
243
|
-
const config = this.config;
|
|
244
|
-
// Name attribute
|
|
245
|
-
const name = this.getAttribute('name');
|
|
246
|
-
if (name) {
|
|
247
|
-
this.#fileInput.name = this.sanitizeValue(name);
|
|
248
|
-
}
|
|
249
|
-
// Accept attribute (file types)
|
|
250
|
-
const accept = this.getAttribute('accept');
|
|
251
|
-
if (accept) {
|
|
252
|
-
this.#fileInput.accept = accept;
|
|
253
|
-
this.#parseAcceptTypes(accept);
|
|
254
|
-
}
|
|
255
|
-
else {
|
|
256
|
-
// Default safe file types based on tier
|
|
257
|
-
const defaultAccept = this.#getDefaultAcceptTypes();
|
|
258
|
-
this.#fileInput.accept = defaultAccept;
|
|
259
|
-
this.#parseAcceptTypes(defaultAccept);
|
|
260
|
-
}
|
|
261
|
-
// Max size
|
|
262
|
-
const maxSize = this.getAttribute('max-size');
|
|
263
|
-
if (maxSize) {
|
|
264
|
-
this.#maxSize = parseInt(maxSize, 10);
|
|
265
|
-
}
|
|
266
|
-
else {
|
|
267
|
-
// Default max size based on tier
|
|
268
|
-
this.#maxSize = this.#getDefaultMaxSize();
|
|
269
|
-
}
|
|
270
|
-
// Multiple files
|
|
271
|
-
if (this.hasAttribute('multiple')) {
|
|
272
|
-
this.#fileInput.multiple = true;
|
|
273
|
-
}
|
|
274
|
-
// Required
|
|
275
|
-
if (this.hasAttribute('required') || config.validation.required) {
|
|
276
|
-
this.#fileInput.required = true;
|
|
277
|
-
}
|
|
278
|
-
// Capture (for mobile camera)
|
|
279
|
-
const capture = this.getAttribute('capture');
|
|
280
|
-
if (capture) {
|
|
281
|
-
this.#fileInput.capture = capture;
|
|
282
|
-
}
|
|
283
|
-
// Disabled
|
|
284
|
-
if (this.hasAttribute('disabled')) {
|
|
285
|
-
this.#fileInput.disabled = true;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
/**
|
|
289
|
-
* Parse accept attribute to extract MIME types
|
|
290
|
-
*
|
|
291
|
-
* @private
|
|
292
|
-
*/
|
|
293
|
-
#parseAcceptTypes(accept) {
|
|
294
|
-
this.#allowedTypes.clear();
|
|
295
|
-
const types = accept.split(',').map(t => t.trim());
|
|
296
|
-
types.forEach((type) => {
|
|
297
|
-
if (type.startsWith('.')) {
|
|
298
|
-
// File extension - convert to MIME type
|
|
299
|
-
const mimeType = this.#extensionToMimeType(type);
|
|
300
|
-
if (mimeType) {
|
|
301
|
-
this.#allowedTypes.add(mimeType);
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
else {
|
|
305
|
-
// MIME type
|
|
306
|
-
this.#allowedTypes.add(type);
|
|
307
|
-
}
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
/**
|
|
311
|
-
* Convert file extension to MIME type
|
|
312
|
-
*
|
|
313
|
-
* @private
|
|
314
|
-
*/
|
|
315
|
-
#extensionToMimeType(extension) {
|
|
316
|
-
const mimeTypes = {
|
|
317
|
-
'.pdf': 'application/pdf',
|
|
318
|
-
'.doc': 'application/msword',
|
|
319
|
-
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
320
|
-
'.xls': 'application/vnd.ms-excel',
|
|
321
|
-
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
322
|
-
'.ppt': 'application/vnd.ms-powerpoint',
|
|
323
|
-
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
324
|
-
'.txt': 'text/plain',
|
|
325
|
-
'.csv': 'text/csv',
|
|
326
|
-
'.jpg': 'image/jpeg',
|
|
327
|
-
'.jpeg': 'image/jpeg',
|
|
328
|
-
'.png': 'image/png',
|
|
329
|
-
'.gif': 'image/gif',
|
|
330
|
-
'.svg': 'image/svg+xml',
|
|
331
|
-
'.zip': 'application/zip',
|
|
332
|
-
'.json': 'application/json'
|
|
333
|
-
};
|
|
334
|
-
return mimeTypes[extension.toLowerCase()] || null;
|
|
335
|
-
}
|
|
336
|
-
/**
|
|
337
|
-
* Get default accept types based on security tier
|
|
338
|
-
*
|
|
339
|
-
* @private
|
|
340
|
-
*/
|
|
341
|
-
#getDefaultAcceptTypes() {
|
|
342
|
-
switch (this.securityTier) {
|
|
343
|
-
case SecurityTier.CRITICAL:
|
|
344
|
-
// Most restrictive - only documents
|
|
345
|
-
return '.pdf,.txt';
|
|
346
|
-
case SecurityTier.SENSITIVE:
|
|
347
|
-
// Documents and images
|
|
348
|
-
return '.pdf,.doc,.docx,.txt,.jpg,.jpeg,.png';
|
|
349
|
-
case SecurityTier.AUTHENTICATED:
|
|
350
|
-
// Common safe file types
|
|
351
|
-
return '.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv,.jpg,.jpeg,.png,.gif';
|
|
352
|
-
case SecurityTier.PUBLIC:
|
|
353
|
-
default:
|
|
354
|
-
// All common file types
|
|
355
|
-
return '.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv,.jpg,.jpeg,.png,.gif,.zip';
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
/**
|
|
359
|
-
* Get default max size based on security tier
|
|
360
|
-
*
|
|
361
|
-
* @private
|
|
362
|
-
*/
|
|
363
|
-
#getDefaultMaxSize() {
|
|
364
|
-
switch (this.securityTier) {
|
|
365
|
-
case SecurityTier.CRITICAL:
|
|
366
|
-
return 2 * 1024 * 1024; // 2MB
|
|
367
|
-
case SecurityTier.SENSITIVE:
|
|
368
|
-
return 5 * 1024 * 1024; // 5MB
|
|
369
|
-
case SecurityTier.AUTHENTICATED:
|
|
370
|
-
return 10 * 1024 * 1024; // 10MB
|
|
371
|
-
case SecurityTier.PUBLIC:
|
|
372
|
-
default:
|
|
373
|
-
return 20 * 1024 * 1024; // 20MB
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
/**
|
|
377
|
-
* Get accept hint text
|
|
378
|
-
*
|
|
379
|
-
* @private
|
|
380
|
-
*/
|
|
381
|
-
#getAcceptHint() {
|
|
382
|
-
const maxSizeMB = (this.#maxSize / (1024 * 1024)).toFixed(1);
|
|
383
|
-
const accept = this.#fileInput.accept;
|
|
384
|
-
return `Accepted: ${accept || 'all files'} (max ${maxSizeMB}MB)`;
|
|
385
|
-
}
|
|
386
|
-
/**
|
|
387
|
-
* Attach event listeners
|
|
388
|
-
*
|
|
389
|
-
* @private
|
|
390
|
-
*/
|
|
391
|
-
#attachEventListeners() {
|
|
392
|
-
// File input change
|
|
393
|
-
this.#fileInput.addEventListener('change', (e) => {
|
|
394
|
-
void this.#handleFileSelect(e);
|
|
395
|
-
});
|
|
396
|
-
// Drag and drop events
|
|
397
|
-
this.#dropZone.addEventListener('dragover', (e) => {
|
|
398
|
-
e.preventDefault();
|
|
399
|
-
e.stopPropagation();
|
|
400
|
-
this.#dropZone.classList.add('drag-over');
|
|
401
|
-
});
|
|
402
|
-
this.#dropZone.addEventListener('dragleave', (e) => {
|
|
403
|
-
e.preventDefault();
|
|
404
|
-
e.stopPropagation();
|
|
405
|
-
this.#dropZone.classList.remove('drag-over');
|
|
406
|
-
});
|
|
407
|
-
this.#dropZone.addEventListener('drop', (e) => {
|
|
408
|
-
e.preventDefault();
|
|
409
|
-
e.stopPropagation();
|
|
410
|
-
this.#dropZone.classList.remove('drag-over');
|
|
411
|
-
const dragEvent = e;
|
|
412
|
-
if (!dragEvent.dataTransfer)
|
|
413
|
-
return;
|
|
414
|
-
const files = dragEvent.dataTransfer.files;
|
|
415
|
-
if (files.length > 0) {
|
|
416
|
-
this.#fileInput.files = files;
|
|
417
|
-
void this.#handleFileSelect({ target: this.#fileInput });
|
|
418
|
-
}
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
/**
|
|
422
|
-
* Handle file selection
|
|
423
|
-
*
|
|
424
|
-
* Security Note: This is where we perform comprehensive file validation
|
|
425
|
-
* including type checking, size limits, and content validation.
|
|
426
|
-
*
|
|
427
|
-
* @private
|
|
428
|
-
*/
|
|
429
|
-
async #handleFileSelect(event) {
|
|
430
|
-
const files = event.target.files;
|
|
431
|
-
if (!files || files.length === 0) {
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
// Check rate limit
|
|
435
|
-
const rateLimitCheck = this.checkRateLimit();
|
|
436
|
-
if (!rateLimitCheck.allowed) {
|
|
437
|
-
this.#showError(`Too many upload attempts. Please wait ${Math.ceil(rateLimitCheck.retryAfter / 1000)} seconds.`);
|
|
438
|
-
this.#fileInput.value = '';
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
// Clear previous errors
|
|
442
|
-
this.#clearErrors();
|
|
443
|
-
// Validate all files
|
|
444
|
-
const validation = await this.#validateFiles(files);
|
|
445
|
-
if (!validation.valid) {
|
|
446
|
-
this.#showError(validation.errors.join(', '));
|
|
447
|
-
this.#fileInput.value = '';
|
|
448
|
-
this.#selectedFiles = null;
|
|
449
|
-
return;
|
|
450
|
-
}
|
|
451
|
-
// Store selected files
|
|
452
|
-
this.#selectedFiles = files;
|
|
453
|
-
// Update filename display
|
|
454
|
-
this.#updateFileNameDisplay(files);
|
|
455
|
-
// Show preview
|
|
456
|
-
this.#showPreview(files);
|
|
457
|
-
// Audit log
|
|
458
|
-
this.audit('files_selected', {
|
|
459
|
-
name: this.#fileInput.name,
|
|
460
|
-
fileCount: files.length,
|
|
461
|
-
totalSize: Array.from(files).reduce((sum, f) => sum + f.size, 0)
|
|
462
|
-
});
|
|
463
|
-
// Dispatch custom event
|
|
464
|
-
this.dispatchEvent(new CustomEvent('secure-file-upload', {
|
|
465
|
-
detail: {
|
|
466
|
-
name: this.#fileInput.name,
|
|
467
|
-
files: Array.from(files),
|
|
468
|
-
tier: this.securityTier
|
|
469
|
-
},
|
|
470
|
-
bubbles: true,
|
|
471
|
-
composed: true
|
|
472
|
-
}));
|
|
473
|
-
}
|
|
474
|
-
/**
|
|
475
|
-
* Validate selected files
|
|
476
|
-
*
|
|
477
|
-
* Security Note: Multi-layered validation including type, size, and content checks.
|
|
478
|
-
*
|
|
479
|
-
* @private
|
|
480
|
-
*/
|
|
481
|
-
async #validateFiles(files) {
|
|
482
|
-
const errors = [];
|
|
483
|
-
// Check file count
|
|
484
|
-
if (!this.#fileInput.multiple && files.length > 1) {
|
|
485
|
-
errors.push('Only one file is allowed');
|
|
486
|
-
}
|
|
487
|
-
for (let i = 0; i < files.length; i++) {
|
|
488
|
-
const file = files[i];
|
|
489
|
-
// Validate file size
|
|
490
|
-
if (file.size > this.#maxSize) {
|
|
491
|
-
const maxSizeMB = (this.#maxSize / (1024 * 1024)).toFixed(1);
|
|
492
|
-
errors.push(`${file.name}: File size exceeds ${maxSizeMB}MB`);
|
|
493
|
-
continue;
|
|
494
|
-
}
|
|
495
|
-
// Validate file type
|
|
496
|
-
if (this.#allowedTypes.size > 0) {
|
|
497
|
-
const isAllowed = this.#isFileTypeAllowed(file);
|
|
498
|
-
if (!isAllowed) {
|
|
499
|
-
errors.push(`${file.name}: File type not allowed`);
|
|
500
|
-
continue;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
// Validate file name (prevent path traversal)
|
|
504
|
-
if (this.#isFileNameDangerous(file.name)) {
|
|
505
|
-
errors.push(`${file.name}: Invalid file name`);
|
|
506
|
-
continue;
|
|
507
|
-
}
|
|
508
|
-
// Content validation for critical tier
|
|
509
|
-
if (this.securityTier === SecurityTier.CRITICAL) {
|
|
510
|
-
const contentCheck = await this.#validateFileContent(file);
|
|
511
|
-
if (!contentCheck.valid) {
|
|
512
|
-
errors.push(`${file.name}: ${contentCheck.error}`);
|
|
513
|
-
continue;
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
// Run scan hook if registered
|
|
517
|
-
if (this.#scanHook) {
|
|
518
|
-
const scanResult = await this.#runScanHook(file);
|
|
519
|
-
if (!scanResult.valid) {
|
|
520
|
-
errors.push(`${file.name}: ${scanResult.reason || 'Rejected by scan'}`);
|
|
521
|
-
continue;
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
return {
|
|
526
|
-
valid: errors.length === 0,
|
|
527
|
-
errors
|
|
528
|
-
};
|
|
529
|
-
}
|
|
530
|
-
/**
|
|
531
|
-
* Check if file type is allowed
|
|
532
|
-
*
|
|
533
|
-
* @private
|
|
534
|
-
*/
|
|
535
|
-
#isFileTypeAllowed(file) {
|
|
536
|
-
// Check MIME type
|
|
537
|
-
if (this.#allowedTypes.has(file.type)) {
|
|
538
|
-
return true;
|
|
539
|
-
}
|
|
540
|
-
// Check wildcard patterns (e.g., image/*)
|
|
541
|
-
for (const allowedType of this.#allowedTypes) {
|
|
542
|
-
if (allowedType.endsWith('/*')) {
|
|
543
|
-
const prefix = allowedType.slice(0, -2);
|
|
544
|
-
if (file.type.startsWith(prefix)) {
|
|
545
|
-
return true;
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
return false;
|
|
550
|
-
}
|
|
551
|
-
/**
|
|
552
|
-
* Check if file name is dangerous
|
|
553
|
-
*
|
|
554
|
-
* Security Note: Prevent path traversal and dangerous file names
|
|
555
|
-
*
|
|
556
|
-
* @private
|
|
557
|
-
*/
|
|
558
|
-
#isFileNameDangerous(fileName) {
|
|
559
|
-
// Check for path traversal attempts
|
|
560
|
-
if (fileName.includes('..') || fileName.includes('/') || fileName.includes('\\')) {
|
|
561
|
-
return true;
|
|
562
|
-
}
|
|
563
|
-
// Check for dangerous file names
|
|
564
|
-
const dangerousNames = ['web.config', '.htaccess', '.env', 'config.php'];
|
|
565
|
-
if (dangerousNames.includes(fileName.toLowerCase())) {
|
|
566
|
-
return true;
|
|
567
|
-
}
|
|
568
|
-
return false;
|
|
569
|
-
}
|
|
570
|
-
/**
|
|
571
|
-
* Run the registered scan hook on a file.
|
|
572
|
-
*
|
|
573
|
-
* Shows a scanning indicator, calls the hook, audits the result, and
|
|
574
|
-
* handles errors gracefully (a hook that throws rejects the file).
|
|
575
|
-
*
|
|
576
|
-
* @private
|
|
577
|
-
*/
|
|
578
|
-
async #runScanHook(file) {
|
|
579
|
-
this.#scanning = true;
|
|
580
|
-
this.#showScanningState(file.name);
|
|
581
|
-
this.audit('scan_started', {
|
|
582
|
-
name: this.#fileInput?.name ?? '',
|
|
583
|
-
fileName: file.name,
|
|
584
|
-
fileSize: file.size
|
|
585
|
-
});
|
|
586
|
-
try {
|
|
587
|
-
const result = await this.#scanHook(file);
|
|
588
|
-
this.audit(result.valid ? 'scan_passed' : 'scan_rejected', {
|
|
589
|
-
name: this.#fileInput?.name ?? '',
|
|
590
|
-
fileName: file.name,
|
|
591
|
-
reason: result.reason ?? ''
|
|
592
|
-
});
|
|
593
|
-
return result;
|
|
594
|
-
}
|
|
595
|
-
catch (err) {
|
|
596
|
-
const message = err instanceof Error ? err.message : 'Scan failed';
|
|
597
|
-
this.audit('scan_error', {
|
|
598
|
-
name: this.#fileInput?.name ?? '',
|
|
599
|
-
fileName: file.name,
|
|
600
|
-
error: message
|
|
601
|
-
});
|
|
602
|
-
return { valid: false, reason: `Scan error: ${message}` };
|
|
603
|
-
}
|
|
604
|
-
finally {
|
|
605
|
-
this.#scanning = false;
|
|
606
|
-
this.#hideScanningState();
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
/**
|
|
610
|
-
* Show a scanning-in-progress indicator on the drop zone
|
|
611
|
-
*
|
|
612
|
-
* @private
|
|
613
|
-
*/
|
|
614
|
-
#showScanningState(fileName) {
|
|
615
|
-
if (this.#dropZone) {
|
|
616
|
-
this.#dropZone.classList.add('scanning');
|
|
617
|
-
}
|
|
618
|
-
if (this.#fileInput) {
|
|
619
|
-
this.#fileInput.disabled = true;
|
|
620
|
-
}
|
|
621
|
-
if (this.#fileNameDisplay) {
|
|
622
|
-
this.#fileNameDisplay.textContent = `Scanning ${fileName}\u2026`;
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
/**
|
|
626
|
-
* Remove the scanning indicator
|
|
627
|
-
*
|
|
628
|
-
* @private
|
|
629
|
-
*/
|
|
630
|
-
#hideScanningState() {
|
|
631
|
-
if (this.#dropZone) {
|
|
632
|
-
this.#dropZone.classList.remove('scanning');
|
|
633
|
-
}
|
|
634
|
-
if (this.#fileInput && !this.hasAttribute('disabled')) {
|
|
635
|
-
this.#fileInput.disabled = false;
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
/**
|
|
639
|
-
* Validate file content
|
|
640
|
-
*
|
|
641
|
-
* Security Note: Basic content validation — checks magic numbers to verify
|
|
642
|
-
* the file content matches its declared MIME type.
|
|
643
|
-
*
|
|
644
|
-
* @private
|
|
645
|
-
*/
|
|
646
|
-
async #validateFileContent(file) {
|
|
647
|
-
try {
|
|
648
|
-
// Read first few bytes to check magic numbers
|
|
649
|
-
const buffer = await file.slice(0, 4).arrayBuffer();
|
|
650
|
-
const bytes = new Uint8Array(buffer);
|
|
651
|
-
// Basic magic number validation for common types
|
|
652
|
-
const magicNumbers = {
|
|
653
|
-
'application/pdf': [0x25, 0x50, 0x44, 0x46], // %PDF
|
|
654
|
-
'image/jpeg': [0xFF, 0xD8, 0xFF],
|
|
655
|
-
'image/png': [0x89, 0x50, 0x4E, 0x47]
|
|
656
|
-
};
|
|
657
|
-
// If we have magic numbers for this type, validate them
|
|
658
|
-
if (magicNumbers[file.type]) {
|
|
659
|
-
const expected = magicNumbers[file.type];
|
|
660
|
-
const matches = expected.every((byte, i) => bytes[i] === byte);
|
|
661
|
-
if (!matches) {
|
|
662
|
-
return {
|
|
663
|
-
valid: false,
|
|
664
|
-
error: 'File content does not match declared type'
|
|
665
|
-
};
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
return { valid: true };
|
|
669
|
-
}
|
|
670
|
-
catch (_error) {
|
|
671
|
-
return {
|
|
672
|
-
valid: false,
|
|
673
|
-
error: 'Failed to validate file content'
|
|
674
|
-
};
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
/**
|
|
678
|
-
* Update the filename display area
|
|
679
|
-
*
|
|
680
|
-
* @private
|
|
681
|
-
*/
|
|
682
|
-
#updateFileNameDisplay(files) {
|
|
683
|
-
if (!this.#fileNameDisplay)
|
|
684
|
-
return;
|
|
685
|
-
if (!files || files.length === 0) {
|
|
686
|
-
this.#fileNameDisplay.textContent = 'No file selected';
|
|
687
|
-
this.#fileNameDisplay.classList.remove('has-file');
|
|
688
|
-
}
|
|
689
|
-
else if (files.length === 1) {
|
|
690
|
-
this.#fileNameDisplay.textContent = files[0].name;
|
|
691
|
-
this.#fileNameDisplay.classList.add('has-file');
|
|
692
|
-
}
|
|
693
|
-
else {
|
|
694
|
-
this.#fileNameDisplay.textContent = `${files.length} files selected`;
|
|
695
|
-
this.#fileNameDisplay.classList.add('has-file');
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
/**
|
|
699
|
-
* Show file preview
|
|
700
|
-
*
|
|
701
|
-
* @private
|
|
702
|
-
*/
|
|
703
|
-
#showPreview(files) {
|
|
704
|
-
this.#previewContainer.innerHTML = '';
|
|
705
|
-
Array.from(files).forEach((file) => {
|
|
706
|
-
const preview = document.createElement('div');
|
|
707
|
-
preview.className = 'file-preview';
|
|
708
|
-
const fileName = document.createElement('div');
|
|
709
|
-
fileName.className = 'file-name';
|
|
710
|
-
fileName.textContent = file.name;
|
|
711
|
-
const fileSize = document.createElement('div');
|
|
712
|
-
fileSize.className = 'file-size';
|
|
713
|
-
fileSize.textContent = this.#formatFileSize(file.size);
|
|
714
|
-
const removeBtn = document.createElement('button');
|
|
715
|
-
removeBtn.className = 'remove-file';
|
|
716
|
-
removeBtn.textContent = '\u2715';
|
|
717
|
-
removeBtn.type = 'button';
|
|
718
|
-
removeBtn.onclick = () => {
|
|
719
|
-
this.#removeFile();
|
|
720
|
-
};
|
|
721
|
-
preview.appendChild(fileName);
|
|
722
|
-
preview.appendChild(fileSize);
|
|
723
|
-
preview.appendChild(removeBtn);
|
|
724
|
-
this.#previewContainer.appendChild(preview);
|
|
725
|
-
});
|
|
726
|
-
}
|
|
727
|
-
/**
|
|
728
|
-
* Format file size for display
|
|
729
|
-
*
|
|
730
|
-
* @private
|
|
731
|
-
*/
|
|
732
|
-
#formatFileSize(bytes) {
|
|
733
|
-
if (bytes < 1024)
|
|
734
|
-
return bytes + ' B';
|
|
735
|
-
if (bytes < 1024 * 1024)
|
|
736
|
-
return (bytes / 1024).toFixed(1) + ' KB';
|
|
737
|
-
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
738
|
-
}
|
|
739
|
-
/**
|
|
740
|
-
* Remove selected file
|
|
741
|
-
*
|
|
742
|
-
* @private
|
|
743
|
-
*/
|
|
744
|
-
#removeFile() {
|
|
745
|
-
this.#fileInput.value = '';
|
|
746
|
-
this.#selectedFiles = null;
|
|
747
|
-
this.#previewContainer.innerHTML = '';
|
|
748
|
-
this.#updateFileNameDisplay(null);
|
|
749
|
-
this.#clearErrors();
|
|
750
|
-
this.audit('file_removed', {
|
|
751
|
-
name: this.#fileInput.name
|
|
752
|
-
});
|
|
753
|
-
}
|
|
754
|
-
/**
|
|
755
|
-
* Show error message
|
|
756
|
-
*
|
|
757
|
-
* @private
|
|
758
|
-
*/
|
|
759
|
-
#showError(message) {
|
|
760
|
-
this.#errorContainer.textContent = message;
|
|
761
|
-
this.#errorContainer.classList.remove('hidden');
|
|
762
|
-
this.#dropZone.classList.add('error');
|
|
763
|
-
}
|
|
764
|
-
/**
|
|
765
|
-
* Clear error messages
|
|
766
|
-
*
|
|
767
|
-
* @private
|
|
768
|
-
*/
|
|
769
|
-
#clearErrors() {
|
|
770
|
-
this.#errorContainer.textContent = '';
|
|
771
|
-
this.#errorContainer.classList.add('hidden');
|
|
772
|
-
this.#dropZone.classList.remove('error');
|
|
773
|
-
}
|
|
774
|
-
/**
|
|
775
|
-
* Get component-specific styles
|
|
776
|
-
*
|
|
777
|
-
* This returns a placeholder that will be replaced by the css-inliner build script
|
|
778
|
-
* with the actual CSS from secure-file-upload.css using design tokens.
|
|
779
|
-
*
|
|
780
|
-
* @private
|
|
781
|
-
*/
|
|
782
|
-
#getComponentStyles() {
|
|
783
|
-
return new URL('./secure-file-upload.css', import.meta.url).href;
|
|
784
|
-
}
|
|
785
|
-
/**
|
|
786
|
-
* Handle attribute changes
|
|
787
|
-
*
|
|
788
|
-
* @protected
|
|
789
|
-
*/
|
|
790
|
-
handleAttributeChange(name, _oldValue, newValue) {
|
|
791
|
-
if (!this.#fileInput)
|
|
792
|
-
return;
|
|
793
|
-
switch (name) {
|
|
794
|
-
case 'disabled':
|
|
795
|
-
this.#fileInput.disabled = this.hasAttribute('disabled');
|
|
796
|
-
break;
|
|
797
|
-
case 'accept':
|
|
798
|
-
this.#fileInput.accept = newValue;
|
|
799
|
-
this.#parseAcceptTypes(newValue);
|
|
800
|
-
break;
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
/**
|
|
804
|
-
* Get selected files
|
|
805
|
-
*
|
|
806
|
-
* @public
|
|
807
|
-
*/
|
|
808
|
-
get files() {
|
|
809
|
-
return this.#selectedFiles;
|
|
810
|
-
}
|
|
811
|
-
/**
|
|
812
|
-
* Get the input name
|
|
813
|
-
*
|
|
814
|
-
* @public
|
|
815
|
-
*/
|
|
816
|
-
get name() {
|
|
817
|
-
return this.#fileInput ? this.#fileInput.name : '';
|
|
818
|
-
}
|
|
819
|
-
/**
|
|
820
|
-
* Check if the upload is valid
|
|
821
|
-
*
|
|
822
|
-
* @public
|
|
823
|
-
*/
|
|
824
|
-
get valid() {
|
|
825
|
-
const required = this.hasAttribute('required') || this.config.validation.required;
|
|
826
|
-
if (required && (!this.#selectedFiles || this.#selectedFiles.length === 0)) {
|
|
827
|
-
return false;
|
|
828
|
-
}
|
|
829
|
-
return true;
|
|
830
|
-
}
|
|
831
|
-
/**
|
|
832
|
-
* Clear selected files
|
|
833
|
-
*
|
|
834
|
-
* @public
|
|
835
|
-
*/
|
|
836
|
-
clear() {
|
|
837
|
-
this.#removeFile();
|
|
838
|
-
}
|
|
839
|
-
/**
|
|
840
|
-
* Register a scan hook function for server-side file scanning.
|
|
841
|
-
*
|
|
842
|
-
* The hook receives each selected file and must return a Promise that
|
|
843
|
-
* resolves to `{ valid: boolean; reason?: string }`. When `valid` is
|
|
844
|
-
* false the file is rejected and the reason is shown to the user.
|
|
845
|
-
*
|
|
846
|
-
* @example
|
|
847
|
-
* ```js
|
|
848
|
-
* const upload = document.querySelector('secure-file-upload');
|
|
849
|
-
* upload.setScanHook(async (file) => {
|
|
850
|
-
* const form = new FormData();
|
|
851
|
-
* form.append('file', file);
|
|
852
|
-
* const res = await fetch('/api/scan', { method: 'POST', body: form });
|
|
853
|
-
* const { clean, threat } = await res.json();
|
|
854
|
-
* return { valid: clean, reason: threat };
|
|
855
|
-
* });
|
|
856
|
-
* ```
|
|
857
|
-
*
|
|
858
|
-
* @public
|
|
859
|
-
*/
|
|
860
|
-
setScanHook(hook) {
|
|
861
|
-
if (typeof hook !== 'function') {
|
|
862
|
-
throw new TypeError('setScanHook expects a function');
|
|
863
|
-
}
|
|
864
|
-
this.#scanHook = hook;
|
|
865
|
-
this.audit('scan_hook_registered', {
|
|
866
|
-
name: this.#fileInput?.name ?? ''
|
|
867
|
-
});
|
|
868
|
-
}
|
|
869
|
-
/**
|
|
870
|
-
* Check whether a scan hook is registered
|
|
871
|
-
*
|
|
872
|
-
* @public
|
|
873
|
-
*/
|
|
874
|
-
get hasScanHook() {
|
|
875
|
-
return this.#scanHook !== null;
|
|
876
|
-
}
|
|
877
|
-
/**
|
|
878
|
-
* Check whether a scan is currently in progress
|
|
879
|
-
*
|
|
880
|
-
* @public
|
|
881
|
-
*/
|
|
882
|
-
get scanning() {
|
|
883
|
-
return this.#scanning;
|
|
884
|
-
}
|
|
885
|
-
/**
|
|
886
|
-
* Cleanup on disconnect
|
|
887
|
-
*/
|
|
888
|
-
disconnectedCallback() {
|
|
889
|
-
super.disconnectedCallback();
|
|
890
|
-
// Clear file references
|
|
891
|
-
this.#selectedFiles = null;
|
|
892
|
-
if (this.#fileInput) {
|
|
893
|
-
this.#fileInput.value = '';
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
// Define the custom element
|
|
898
|
-
customElements.define('secure-file-upload', SecureFileUpload);
|
|
899
|
-
export default SecureFileUpload;
|
|
900
|
-
//# sourceMappingURL=secure-file-upload.js.map
|
|
33
|
+
*/import{SecureBaseComponent as m}from"../../core/base-component.js";import{SecurityTier as o}from"../../core/security-config.js";class p extends m{#e=null;#o=null;#s=null;#a=null;#t=null;#i=null;#n=null;#c=`secure-file-upload-${Math.random().toString(36).substring(2,11)}`;#r=new Set;#l=5*1024*1024;#d=null;#h=!1;static get observedAttributes(){return[...super.observedAttributes,"name","label","accept","max-size","multiple","required","capture"]}constructor(){super()}render(){const e=document.createDocumentFragment(),t=document.createElement("div");t.className="file-upload-container",t.setAttribute("part","container");const i=this.getAttribute("label");i&&(this.#o=document.createElement("label"),this.#o.htmlFor=this.#c,this.#o.textContent=this.sanitizeValue(i),this.#o.setAttribute("part","label"),t.appendChild(this.#o)),this.#t=document.createElement("div"),this.#t.className="drop-zone",this.#t.setAttribute("part","wrapper"),this.#e=document.createElement("input"),this.#e.type="file",this.#e.id=this.#c,this.#e.className="file-input",this.#e.setAttribute("part","input"),this.#x(),this.#y();const s=document.createElement("div");s.className="drop-zone-content has-name";const n=document.createElement("span");n.className="file-cta";const r=document.createElement("span");r.className="drop-icon";const a=document.createElementNS("http://www.w3.org/2000/svg","svg");a.setAttribute("width","16"),a.setAttribute("height","16"),a.setAttribute("viewBox","0 0 24 24"),a.setAttribute("fill","none"),a.setAttribute("stroke","currentColor"),a.setAttribute("stroke-width","2"),a.setAttribute("stroke-linecap","round"),a.setAttribute("stroke-linejoin","round"),a.setAttribute("aria-hidden","true"),a.setAttribute("focusable","false");const c=document.createElementNS("http://www.w3.org/2000/svg","path");c.setAttribute("d","M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4");const u=document.createElementNS("http://www.w3.org/2000/svg","polyline");u.setAttribute("points","17 8 12 3 7 8");const l=document.createElementNS("http://www.w3.org/2000/svg","line");l.setAttribute("x1","12"),l.setAttribute("y1","3"),l.setAttribute("x2","12"),l.setAttribute("y2","15"),a.appendChild(c),a.appendChild(u),a.appendChild(l),r.appendChild(a),n.appendChild(r);const d=document.createElement("span");d.className="drop-text",d.textContent="Choose a file\u2026",n.appendChild(d),s.appendChild(n),this.#i=document.createElement("span"),this.#i.className="file-name-display",this.#i.textContent="No file selected",s.appendChild(this.#i),this.#t.appendChild(this.#e),this.#t.appendChild(s),t.appendChild(this.#t);const h=document.createElement("div");return h.className="drop-hint",h.textContent=this.#w(),t.appendChild(h),this.#a=document.createElement("div"),this.#a.className="preview-container",t.appendChild(this.#a),this.#s=document.createElement("div"),this.#s.className="error-container hidden",this.#s.setAttribute("role","alert"),this.#s.setAttribute("part","error"),this.#s.id=`${this.#c}-error`,t.appendChild(this.#s),this.#e.setAttribute("aria-describedby",`${this.#c}-error`),this.addComponentStyles(this.#k()),e.appendChild(t),e}#x(){const e=this.config,t=this.getAttribute("name");t&&(this.#e.name=this.sanitizeValue(t));const i=this.getAttribute("accept");if(i)this.#e.accept=i,this.#u(i);else{const r=this.#C();this.#e.accept=r,this.#u(r)}const s=this.getAttribute("max-size");s?this.#l=parseInt(s,10):this.#l=this.#A(),this.hasAttribute("multiple")&&(this.#e.multiple=!0),(this.hasAttribute("required")||e.validation.required)&&(this.#e.required=!0);const n=this.getAttribute("capture");n&&(this.#e.capture=n),this.hasAttribute("disabled")&&(this.#e.disabled=!0)}#u(e){this.#r.clear(),e.split(",").map(i=>i.trim()).forEach(i=>{if(i.startsWith(".")){const s=this.#b(i);s&&this.#r.add(s)}else this.#r.add(i)})}#b(e){return{".pdf":"application/pdf",".doc":"application/msword",".docx":"application/vnd.openxmlformats-officedocument.wordprocessingml.document",".xls":"application/vnd.ms-excel",".xlsx":"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",".ppt":"application/vnd.ms-powerpoint",".pptx":"application/vnd.openxmlformats-officedocument.presentationml.presentation",".txt":"text/plain",".csv":"text/csv",".jpg":"image/jpeg",".jpeg":"image/jpeg",".png":"image/png",".gif":"image/gif",".svg":"image/svg+xml",".zip":"application/zip",".json":"application/json"}[e.toLowerCase()]||null}#C(){switch(this.securityTier){case o.CRITICAL:return".pdf,.txt";case o.SENSITIVE:return".pdf,.doc,.docx,.txt,.jpg,.jpeg,.png";case o.AUTHENTICATED:return".pdf,.doc,.docx,.xls,.xlsx,.txt,.csv,.jpg,.jpeg,.png,.gif";case o.PUBLIC:default:return".pdf,.doc,.docx,.xls,.xlsx,.txt,.csv,.jpg,.jpeg,.png,.gif,.zip"}}#A(){switch(this.securityTier){case o.CRITICAL:return 2*1024*1024;case o.SENSITIVE:return 5*1024*1024;case o.AUTHENTICATED:return 10*1024*1024;case o.PUBLIC:default:return 20*1024*1024}}#w(){const e=(this.#l/1048576).toFixed(1);return`Accepted: ${this.#e.accept||"all files"} (max ${e}MB)`}#y(){this.#e.addEventListener("change",e=>{this.#p(e)}),this.#t.addEventListener("dragover",e=>{e.preventDefault(),e.stopPropagation(),this.#t.classList.add("drag-over")}),this.#t.addEventListener("dragleave",e=>{e.preventDefault(),e.stopPropagation(),this.#t.classList.remove("drag-over")}),this.#t.addEventListener("drop",e=>{e.preventDefault(),e.stopPropagation(),this.#t.classList.remove("drag-over");const t=e;if(!t.dataTransfer)return;const i=t.dataTransfer.files;i.length>0&&(this.#e.files=i,this.#p({target:this.#e}))})}async#p(e){const t=e.target.files;if(!t||t.length===0)return;const i=this.checkRateLimit();if(!i.allowed){this.#g(`Too many upload attempts. Please wait ${Math.ceil(i.retryAfter/1e3)} seconds.`),this.#e.value="";return}this.#v();const s=await this.#E(t);if(!s.valid){this.#g(s.errors.join(", ")),this.#e.value="",this.#n=null;return}this.#n=t,this.#m(t),this.#I(t),this.audit("files_selected",{name:this.#e.name,fileCount:t.length,totalSize:Array.from(t).reduce((n,r)=>n+r.size,0)}),this.dispatchEvent(new CustomEvent("secure-file-upload",{detail:{name:this.#e.name,files:Array.from(t),tier:this.securityTier},bubbles:!0,composed:!0}))}async#E(e){const t=[];!this.#e.multiple&&e.length>1&&t.push("Only one file is allowed");for(let i=0;i<e.length;i++){const s=e[i];if(s.size>this.#l){const n=(this.#l/1048576).toFixed(1);t.push(`${s.name}: File size exceeds ${n}MB`);continue}if(this.#r.size>0&&!this.#S(s)){t.push(`${s.name}: File type not allowed`);continue}if(this.#T(s.name)){t.push(`${s.name}: Invalid file name`);continue}if(this.securityTier===o.CRITICAL){const n=await this.#F(s);if(!n.valid){t.push(`${s.name}: ${n.error}`);continue}}if(this.#d){const n=await this.#N(s);if(!n.valid){t.push(`${s.name}: ${n.reason||"Rejected by scan"}`);continue}}}return{valid:t.length===0,errors:t}}#S(e){if(this.#r.has(e.type))return!0;for(const t of this.#r)if(t.endsWith("/*")){const i=t.slice(0,-2);if(e.type.startsWith(i))return!0}return!1}#T(e){return!!(e.includes("..")||e.includes("/")||e.includes("\\")||["web.config",".htaccess",".env","config.php"].includes(e.toLowerCase()))}async#N(e){this.#h=!0,this.#L(e.name),this.audit("scan_started",{name:this.#e?.name??"",fileName:e.name,fileSize:e.size});try{const t=await this.#d(e);return this.audit(t.valid?"scan_passed":"scan_rejected",{name:this.#e?.name??"",fileName:e.name,reason:t.reason??""}),t}catch(t){const i=t instanceof Error?t.message:"Scan failed";return this.audit("scan_error",{name:this.#e?.name??"",fileName:e.name,error:i}),{valid:!1,reason:`Scan error: ${i}`}}finally{this.#h=!1,this.#z()}}#L(e){this.#t&&this.#t.classList.add("scanning"),this.#e&&(this.#e.disabled=!0),this.#i&&(this.#i.textContent=`Scanning ${e}\u2026`)}#z(){this.#t&&this.#t.classList.remove("scanning"),this.#e&&!this.hasAttribute("disabled")&&(this.#e.disabled=!1)}async#F(e){try{const t=await e.slice(0,4).arrayBuffer(),i=new Uint8Array(t),s={"application/pdf":[37,80,68,70],"image/jpeg":[255,216,255],"image/png":[137,80,78,71]};return s[e.type]&&!s[e.type].every((a,c)=>i[c]===a)?{valid:!1,error:"File content does not match declared type"}:{valid:!0}}catch{return{valid:!1,error:"Failed to validate file content"}}}#m(e){this.#i&&(!e||e.length===0?(this.#i.textContent="No file selected",this.#i.classList.remove("has-file")):e.length===1?(this.#i.textContent=e[0].name,this.#i.classList.add("has-file")):(this.#i.textContent=`${e.length} files selected`,this.#i.classList.add("has-file")))}#I(e){this.#a.innerHTML="",Array.from(e).forEach(t=>{const i=document.createElement("div");i.className="file-preview";const s=document.createElement("div");s.className="file-name",s.textContent=t.name;const n=document.createElement("div");n.className="file-size",n.textContent=this.#j(t.size);const r=document.createElement("button");r.className="remove-file",r.textContent="\u2715",r.type="button",r.onclick=()=>{this.#f()},i.appendChild(s),i.appendChild(n),i.appendChild(r),this.#a.appendChild(i)})}#j(e){return e<1024?e+" B":e<1024*1024?(e/1024).toFixed(1)+" KB":(e/(1024*1024)).toFixed(1)+" MB"}#f(){this.#e.value="",this.#n=null,this.#a.innerHTML="",this.#m(null),this.#v(),this.audit("file_removed",{name:this.#e.name})}#g(e){this.#s.textContent=e,this.#s.classList.remove("hidden"),this.#t.classList.add("error")}#v(){this.#s.textContent="",this.#s.classList.add("hidden"),this.#t.classList.remove("error")}#k(){return new URL("./secure-file-upload.css",import.meta.url).href}handleAttributeChange(e,t,i){if(this.#e)switch(e){case"disabled":this.#e.disabled=this.hasAttribute("disabled");break;case"accept":this.#e.accept=i,this.#u(i);break}}get files(){return this.#n}get name(){return this.#e?this.#e.name:""}get valid(){return!((this.hasAttribute("required")||this.config.validation.required)&&(!this.#n||this.#n.length===0))}clear(){this.#f()}setScanHook(e){if(typeof e!="function")throw new TypeError("setScanHook expects a function");this.#d=e,this.audit("scan_hook_registered",{name:this.#e?.name??""})}get hasScanHook(){return this.#d!==null}get scanning(){return this.#h}disconnectedCallback(){super.disconnectedCallback(),this.#n=null,this.#e&&(this.#e.value="")}}customElements.define("secure-file-upload",p);var x=p;export{p as SecureFileUpload,x as default};
|