secure-ui-components 0.1.0-beta.1

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