secure-ui-components 0.2.3 → 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.
@@ -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};