@msal95/fileguard 0.1.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.
- package/README.md +530 -0
- package/index.js +2 -0
- package/package.json +122 -0
- package/src/adapters/express.js +86 -0
- package/src/adapters/fastify.js +105 -0
- package/src/adapters/index.js +3 -0
- package/src/adapters/nextjs.js +74 -0
- package/src/audit/logger.js +32 -0
- package/src/core/rateLimiter.js +55 -0
- package/src/core/sanitizer.js +62 -0
- package/src/core/validator.js +163 -0
- package/src/errors/UploadError.js +43 -0
- package/src/index.js +65 -0
- package/src/react/DropZone.jsx +146 -0
- package/src/react/FilePreview.jsx +152 -0
- package/src/react/ProgressBar.jsx +82 -0
- package/src/react/UploadButton.jsx +123 -0
- package/src/react/index.js +4 -0
- package/src/scanners/clamav.js +50 -0
- package/src/scanners/magicBytes.js +62 -0
- package/src/scanners/polyglot.js +58 -0
- package/src/scanners/virustotal.js +85 -0
- package/src/scanners/zipBomb.js +85 -0
- package/src/storage/cloudinary.js +56 -0
- package/src/storage/index.js +27 -0
- package/src/storage/local.js +39 -0
- package/src/storage/s3.js +62 -0
- package/types/index.d.ts +380 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { useState, useRef, useCallback } from 'react'
|
|
2
|
+
|
|
3
|
+
// ── Client-side validation ────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
function clientValidate(file, accept, maxSize) {
|
|
6
|
+
const ext = file.name.split('.').pop().toLowerCase()
|
|
7
|
+
if (accept.length > 0 && !accept.map((e) => e.toLowerCase()).includes(ext)) {
|
|
8
|
+
return { valid: false, error: 'INVALID_EXTENSION', message: `File type .${ext} is not allowed` }
|
|
9
|
+
}
|
|
10
|
+
if (maxSize != null && file.size > maxSize) {
|
|
11
|
+
return { valid: false, error: 'FILE_TOO_LARGE', message: `File size exceeds the ${maxSize}-byte limit` }
|
|
12
|
+
}
|
|
13
|
+
return { valid: true }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ── Default styles (overridable via CSS variables) ────────────────────────────
|
|
17
|
+
|
|
18
|
+
const S = {
|
|
19
|
+
zone: {
|
|
20
|
+
display: 'flex',
|
|
21
|
+
flexDirection: 'column',
|
|
22
|
+
alignItems: 'center',
|
|
23
|
+
justifyContent: 'center',
|
|
24
|
+
padding: 'var(--fg-padding, 40px 24px)',
|
|
25
|
+
border: '2px dashed var(--fg-border, #d1d5db)',
|
|
26
|
+
borderRadius: 'var(--fg-radius, 10px)',
|
|
27
|
+
backgroundColor: 'var(--fg-bg, #fafafa)',
|
|
28
|
+
color: 'var(--fg-text, #6b7280)',
|
|
29
|
+
cursor: 'pointer',
|
|
30
|
+
transition: 'border-color 0.15s, background-color 0.15s',
|
|
31
|
+
userSelect: 'none',
|
|
32
|
+
outline: 'none',
|
|
33
|
+
},
|
|
34
|
+
zoneActive: {
|
|
35
|
+
borderColor: 'var(--fg-primary, #2563eb)',
|
|
36
|
+
backgroundColor: 'var(--fg-bg-active, #eff6ff)',
|
|
37
|
+
color: 'var(--fg-text-active, #1d4ed8)',
|
|
38
|
+
},
|
|
39
|
+
hint: {
|
|
40
|
+
margin: 0,
|
|
41
|
+
fontSize: 'var(--fg-font-size, 14px)',
|
|
42
|
+
lineHeight: 1.5,
|
|
43
|
+
pointerEvents: 'none',
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Drag-and-drop file upload zone with optional client-side validation.
|
|
51
|
+
*
|
|
52
|
+
* This is a client-side convenience layer only — always validate again on
|
|
53
|
+
* the server using fileguard's validation pipeline.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} props
|
|
56
|
+
* @param {Function} [props.onUpload] Called with the File when it passes validation.
|
|
57
|
+
* @param {Function} [props.onError] Called with { error, message, file } on validation failure.
|
|
58
|
+
* @param {string[]} [props.accept] Allowed file extensions (e.g. ['png', 'jpg']).
|
|
59
|
+
* @param {number} [props.maxSize] Max file size in bytes.
|
|
60
|
+
* @param {boolean} [props.headless] If true, renders no inline styles.
|
|
61
|
+
* @param {boolean} [props.multiple] If true, allows picking multiple files (all validated).
|
|
62
|
+
* @param {*} [props.children] Override the default inner content.
|
|
63
|
+
* @param {string} [props.className]
|
|
64
|
+
* @param {object} [props.style] Additional style overrides.
|
|
65
|
+
*/
|
|
66
|
+
export function DropZone({
|
|
67
|
+
onUpload,
|
|
68
|
+
onError,
|
|
69
|
+
accept = [],
|
|
70
|
+
maxSize,
|
|
71
|
+
headless = false,
|
|
72
|
+
multiple = false,
|
|
73
|
+
children,
|
|
74
|
+
className,
|
|
75
|
+
style,
|
|
76
|
+
...rest
|
|
77
|
+
}) {
|
|
78
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
79
|
+
const inputRef = useRef(null)
|
|
80
|
+
|
|
81
|
+
const handleFile = useCallback(
|
|
82
|
+
(file) => {
|
|
83
|
+
const result = clientValidate(file, accept, maxSize)
|
|
84
|
+
if (!result.valid) {
|
|
85
|
+
onError?.({ error: result.error, message: result.message, file })
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
onUpload?.(file)
|
|
89
|
+
},
|
|
90
|
+
[accept, maxSize, onUpload, onError]
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const handleFiles = useCallback(
|
|
94
|
+
(files) => Array.from(files).forEach(handleFile),
|
|
95
|
+
[handleFile]
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const onDragOver = (e) => { e.preventDefault(); setIsDragging(true) }
|
|
99
|
+
const onDragLeave = (e) => { e.preventDefault(); setIsDragging(false) }
|
|
100
|
+
const onDrop = (e) => {
|
|
101
|
+
e.preventDefault()
|
|
102
|
+
setIsDragging(false)
|
|
103
|
+
if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files)
|
|
104
|
+
}
|
|
105
|
+
const onClick = () => inputRef.current?.click()
|
|
106
|
+
const onKeyDown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick() } }
|
|
107
|
+
const onChange = (e) => {
|
|
108
|
+
if (e.target.files?.length) handleFiles(e.target.files)
|
|
109
|
+
e.target.value = ''
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const computedStyle = headless
|
|
113
|
+
? style
|
|
114
|
+
: { ...S.zone, ...(isDragging ? S.zoneActive : {}), ...style }
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div
|
|
118
|
+
role="button"
|
|
119
|
+
tabIndex={0}
|
|
120
|
+
aria-label="File upload drop zone. Press Enter or Space to open the file picker."
|
|
121
|
+
className={className}
|
|
122
|
+
style={computedStyle}
|
|
123
|
+
onDragOver={onDragOver}
|
|
124
|
+
onDragLeave={onDragLeave}
|
|
125
|
+
onDrop={onDrop}
|
|
126
|
+
onClick={onClick}
|
|
127
|
+
onKeyDown={onKeyDown}
|
|
128
|
+
{...rest}
|
|
129
|
+
>
|
|
130
|
+
<input
|
|
131
|
+
ref={inputRef}
|
|
132
|
+
type="file"
|
|
133
|
+
style={{ display: 'none' }}
|
|
134
|
+
accept={accept.length ? accept.map((e) => `.${e}`).join(',') : undefined}
|
|
135
|
+
multiple={multiple}
|
|
136
|
+
onChange={onChange}
|
|
137
|
+
tabIndex={-1}
|
|
138
|
+
/>
|
|
139
|
+
{children ?? (
|
|
140
|
+
<p style={headless ? undefined : S.hint}>
|
|
141
|
+
Drop a file here or <strong>click to browse</strong>
|
|
142
|
+
</p>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'])
|
|
6
|
+
|
|
7
|
+
function formatBytes(bytes) {
|
|
8
|
+
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
9
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
10
|
+
return `${bytes} B`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ── Default styles (overridable via CSS variables) ────────────────────────────
|
|
14
|
+
|
|
15
|
+
const S = {
|
|
16
|
+
container: {
|
|
17
|
+
display: 'flex',
|
|
18
|
+
alignItems: 'center',
|
|
19
|
+
gap: '12px',
|
|
20
|
+
padding: 'var(--fg-padding, 12px)',
|
|
21
|
+
border: '1px solid var(--fg-border, #e5e7eb)',
|
|
22
|
+
borderRadius: 'var(--fg-radius, 8px)',
|
|
23
|
+
backgroundColor: 'var(--fg-bg, #ffffff)',
|
|
24
|
+
},
|
|
25
|
+
thumbnail: {
|
|
26
|
+
width: '48px',
|
|
27
|
+
height: '48px',
|
|
28
|
+
objectFit: 'cover',
|
|
29
|
+
borderRadius: '4px',
|
|
30
|
+
flexShrink: 0,
|
|
31
|
+
display: 'block',
|
|
32
|
+
},
|
|
33
|
+
iconBox: {
|
|
34
|
+
width: '48px',
|
|
35
|
+
height: '48px',
|
|
36
|
+
flexShrink: 0,
|
|
37
|
+
display: 'flex',
|
|
38
|
+
alignItems: 'center',
|
|
39
|
+
justifyContent: 'center',
|
|
40
|
+
backgroundColor: 'var(--fg-bg-active, #eff6ff)',
|
|
41
|
+
borderRadius: '4px',
|
|
42
|
+
fontSize: '22px',
|
|
43
|
+
},
|
|
44
|
+
info: {
|
|
45
|
+
flex: 1,
|
|
46
|
+
minWidth: 0,
|
|
47
|
+
},
|
|
48
|
+
name: {
|
|
49
|
+
fontSize: 'var(--fg-font-size, 14px)',
|
|
50
|
+
fontWeight: '500',
|
|
51
|
+
color: 'var(--fg-text, #111827)',
|
|
52
|
+
overflow: 'hidden',
|
|
53
|
+
textOverflow: 'ellipsis',
|
|
54
|
+
whiteSpace: 'nowrap',
|
|
55
|
+
margin: 0,
|
|
56
|
+
},
|
|
57
|
+
meta: {
|
|
58
|
+
fontSize: '12px',
|
|
59
|
+
color: 'var(--fg-text-muted, #9ca3af)',
|
|
60
|
+
margin: '2px 0 0',
|
|
61
|
+
},
|
|
62
|
+
removeBtn: {
|
|
63
|
+
flexShrink: 0,
|
|
64
|
+
background: 'none',
|
|
65
|
+
border: 'none',
|
|
66
|
+
cursor: 'pointer',
|
|
67
|
+
color: 'var(--fg-text-muted, #9ca3af)',
|
|
68
|
+
fontSize: '20px',
|
|
69
|
+
lineHeight: 1,
|
|
70
|
+
padding: '4px',
|
|
71
|
+
display: 'flex',
|
|
72
|
+
alignItems: 'center',
|
|
73
|
+
justifyContent: 'center',
|
|
74
|
+
borderRadius: '4px',
|
|
75
|
+
transition: 'color 0.15s',
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Preview a selected file before it is uploaded.
|
|
83
|
+
* Displays a thumbnail for images and a generic icon for other file types.
|
|
84
|
+
*
|
|
85
|
+
* @param {object} props
|
|
86
|
+
* @param {File} props.file The browser File object to preview.
|
|
87
|
+
* @param {Function} [props.onRemove] Called when the user clicks the remove button.
|
|
88
|
+
* @param {boolean} [props.headless] If true, renders only semantic markup with no inline styles.
|
|
89
|
+
* @param {string} [props.className]
|
|
90
|
+
* @param {object} [props.style] Additional style overrides.
|
|
91
|
+
*/
|
|
92
|
+
export function FilePreview({ file, onRemove, headless = false, className, style, ...rest }) {
|
|
93
|
+
const [previewUrl, setPreviewUrl] = useState(null)
|
|
94
|
+
const isImage = file != null && IMAGE_TYPES.has(file.type)
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!isImage || file == null) {
|
|
98
|
+
setPreviewUrl(null)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
const url = URL.createObjectURL(file)
|
|
102
|
+
setPreviewUrl(url)
|
|
103
|
+
return () => URL.revokeObjectURL(url)
|
|
104
|
+
}, [file, isImage])
|
|
105
|
+
|
|
106
|
+
if (file == null) return null
|
|
107
|
+
|
|
108
|
+
if (headless) {
|
|
109
|
+
return (
|
|
110
|
+
<div className={className} style={style} {...rest}>
|
|
111
|
+
{isImage && previewUrl && <img src={previewUrl} alt={file.name} />}
|
|
112
|
+
<span>{file.name}</span>
|
|
113
|
+
<span>{formatBytes(file.size)}</span>
|
|
114
|
+
<span>{file.type || 'unknown'}</span>
|
|
115
|
+
{onRemove && (
|
|
116
|
+
<button type="button" onClick={onRemove} aria-label={`Remove ${file.name}`}>
|
|
117
|
+
Remove
|
|
118
|
+
</button>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className={className} style={{ ...S.container, ...style }} {...rest}>
|
|
126
|
+
{isImage && previewUrl ? (
|
|
127
|
+
<img src={previewUrl} alt="" style={S.thumbnail} aria-hidden="true" />
|
|
128
|
+
) : (
|
|
129
|
+
<div style={S.iconBox} aria-hidden="true">
|
|
130
|
+
📄
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
<div style={S.info}>
|
|
135
|
+
<p style={S.name} title={file.name}>{file.name}</p>
|
|
136
|
+
<p style={S.meta}>{formatBytes(file.size)} · {file.type || 'unknown type'}</p>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{onRemove && (
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
onClick={onRemove}
|
|
143
|
+
style={S.removeBtn}
|
|
144
|
+
aria-label={`Remove ${file.name}`}
|
|
145
|
+
title="Remove file"
|
|
146
|
+
>
|
|
147
|
+
×
|
|
148
|
+
</button>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// ── Default styles (overridable via CSS variables) ────────────────────────────
|
|
2
|
+
|
|
3
|
+
const S = {
|
|
4
|
+
wrapper: {
|
|
5
|
+
width: '100%',
|
|
6
|
+
},
|
|
7
|
+
labelRow: {
|
|
8
|
+
display: 'flex',
|
|
9
|
+
justifyContent: 'space-between',
|
|
10
|
+
alignItems: 'baseline',
|
|
11
|
+
marginBottom: '6px',
|
|
12
|
+
fontSize: 'var(--fg-font-size, 13px)',
|
|
13
|
+
color: 'var(--fg-text, #6b7280)',
|
|
14
|
+
},
|
|
15
|
+
track: {
|
|
16
|
+
width: '100%',
|
|
17
|
+
height: 'var(--fg-bar-height, 8px)',
|
|
18
|
+
backgroundColor: 'var(--fg-bar-bg, #e5e7eb)',
|
|
19
|
+
borderRadius: 'var(--fg-radius, 999px)',
|
|
20
|
+
overflow: 'hidden',
|
|
21
|
+
},
|
|
22
|
+
fill: {
|
|
23
|
+
height: '100%',
|
|
24
|
+
backgroundColor: 'var(--fg-primary, #2563eb)',
|
|
25
|
+
borderRadius: 'inherit',
|
|
26
|
+
transition: 'width 0.25s ease',
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Upload progress indicator.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} props
|
|
36
|
+
* @param {number} [props.progress] Value from 0–100. Clamped automatically.
|
|
37
|
+
* @param {string} [props.label] Optional label shown above the bar.
|
|
38
|
+
* @param {boolean} [props.headless] If true, renders only semantic markup with no inline styles.
|
|
39
|
+
* @param {string} [props.className]
|
|
40
|
+
* @param {object} [props.style] Additional style overrides for the outermost element.
|
|
41
|
+
*/
|
|
42
|
+
export function ProgressBar({ progress = 0, label, headless = false, className, style, ...rest }) {
|
|
43
|
+
const clamped = Math.max(0, Math.min(100, progress))
|
|
44
|
+
|
|
45
|
+
if (headless) {
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
role="progressbar"
|
|
49
|
+
aria-valuenow={clamped}
|
|
50
|
+
aria-valuemin={0}
|
|
51
|
+
aria-valuemax={100}
|
|
52
|
+
aria-label={label ?? 'Upload progress'}
|
|
53
|
+
className={className}
|
|
54
|
+
style={style}
|
|
55
|
+
{...rest}
|
|
56
|
+
>
|
|
57
|
+
<div style={{ width: `${clamped}%` }} />
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className={className} style={{ ...S.wrapper, ...style }} {...rest}>
|
|
64
|
+
{label != null && (
|
|
65
|
+
<div style={S.labelRow} aria-hidden="true">
|
|
66
|
+
<span>{label}</span>
|
|
67
|
+
<span>{clamped}%</span>
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
<div
|
|
71
|
+
role="progressbar"
|
|
72
|
+
aria-valuenow={clamped}
|
|
73
|
+
aria-valuemin={0}
|
|
74
|
+
aria-valuemax={100}
|
|
75
|
+
aria-label={label ?? 'Upload progress'}
|
|
76
|
+
style={S.track}
|
|
77
|
+
>
|
|
78
|
+
<div style={{ ...S.fill, width: `${clamped}%` }} />
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useRef, useCallback } from 'react'
|
|
2
|
+
|
|
3
|
+
// ── Client-side validation ────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
function clientValidate(file, accept, maxSize) {
|
|
6
|
+
const ext = file.name.split('.').pop().toLowerCase()
|
|
7
|
+
if (accept.length > 0 && !accept.map((e) => e.toLowerCase()).includes(ext)) {
|
|
8
|
+
return { valid: false, error: 'INVALID_EXTENSION', message: `File type .${ext} is not allowed` }
|
|
9
|
+
}
|
|
10
|
+
if (maxSize != null && file.size > maxSize) {
|
|
11
|
+
return { valid: false, error: 'FILE_TOO_LARGE', message: `File size exceeds the ${maxSize}-byte limit` }
|
|
12
|
+
}
|
|
13
|
+
return { valid: true }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ── Default styles (overridable via CSS variables) ────────────────────────────
|
|
17
|
+
|
|
18
|
+
const S = {
|
|
19
|
+
button: {
|
|
20
|
+
display: 'inline-flex',
|
|
21
|
+
alignItems: 'center',
|
|
22
|
+
gap: '6px',
|
|
23
|
+
padding: 'var(--fg-btn-padding, 8px 18px)',
|
|
24
|
+
border: 'none',
|
|
25
|
+
borderRadius: 'var(--fg-radius, 6px)',
|
|
26
|
+
backgroundColor: 'var(--fg-primary, #2563eb)',
|
|
27
|
+
color: 'var(--fg-btn-text, #ffffff)',
|
|
28
|
+
fontSize: 'var(--fg-font-size, 14px)',
|
|
29
|
+
fontWeight: '500',
|
|
30
|
+
lineHeight: 1.5,
|
|
31
|
+
cursor: 'pointer',
|
|
32
|
+
transition: 'background-color 0.15s, opacity 0.15s',
|
|
33
|
+
},
|
|
34
|
+
disabled: {
|
|
35
|
+
opacity: 0.5,
|
|
36
|
+
cursor: 'not-allowed',
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Simple file-picker button with optional client-side validation.
|
|
44
|
+
*
|
|
45
|
+
* This is a client-side convenience layer only — always validate again on
|
|
46
|
+
* the server using fileguard's validation pipeline.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} props
|
|
49
|
+
* @param {Function} [props.onUpload] Called with each File that passes validation.
|
|
50
|
+
* @param {Function} [props.onError] Called with { error, message, file } on validation failure.
|
|
51
|
+
* @param {string[]} [props.accept] Allowed file extensions (e.g. ['pdf', 'docx']).
|
|
52
|
+
* @param {number} [props.maxSize] Max file size in bytes.
|
|
53
|
+
* @param {boolean} [props.multiple] If true, allows picking multiple files.
|
|
54
|
+
* @param {boolean} [props.headless] If true, renders no inline styles on the button.
|
|
55
|
+
* @param {boolean} [props.disabled]
|
|
56
|
+
* @param {*} [props.children] Button label content.
|
|
57
|
+
* @param {string} [props.className]
|
|
58
|
+
* @param {object} [props.style] Additional style overrides.
|
|
59
|
+
*/
|
|
60
|
+
export function UploadButton({
|
|
61
|
+
onUpload,
|
|
62
|
+
onError,
|
|
63
|
+
accept = [],
|
|
64
|
+
maxSize,
|
|
65
|
+
multiple = false,
|
|
66
|
+
headless = false,
|
|
67
|
+
disabled = false,
|
|
68
|
+
children = 'Upload File',
|
|
69
|
+
className,
|
|
70
|
+
style,
|
|
71
|
+
...rest
|
|
72
|
+
}) {
|
|
73
|
+
const inputRef = useRef(null)
|
|
74
|
+
|
|
75
|
+
const handleFiles = useCallback(
|
|
76
|
+
(files) => {
|
|
77
|
+
Array.from(files).forEach((file) => {
|
|
78
|
+
const result = clientValidate(file, accept, maxSize)
|
|
79
|
+
if (!result.valid) {
|
|
80
|
+
onError?.({ error: result.error, message: result.message, file })
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
onUpload?.(file)
|
|
84
|
+
})
|
|
85
|
+
},
|
|
86
|
+
[accept, maxSize, onUpload, onError]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
const onClick = () => { if (!disabled) inputRef.current?.click() }
|
|
90
|
+
const onChange = (e) => {
|
|
91
|
+
if (e.target.files?.length) handleFiles(e.target.files)
|
|
92
|
+
e.target.value = ''
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const computedStyle = headless
|
|
96
|
+
? style
|
|
97
|
+
: { ...S.button, ...(disabled ? S.disabled : {}), ...style }
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<>
|
|
101
|
+
<input
|
|
102
|
+
ref={inputRef}
|
|
103
|
+
type="file"
|
|
104
|
+
style={{ display: 'none' }}
|
|
105
|
+
accept={accept.length ? accept.map((e) => `.${e}`).join(',') : undefined}
|
|
106
|
+
multiple={multiple}
|
|
107
|
+
disabled={disabled}
|
|
108
|
+
onChange={onChange}
|
|
109
|
+
tabIndex={-1}
|
|
110
|
+
/>
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
className={className}
|
|
114
|
+
style={computedStyle}
|
|
115
|
+
disabled={disabled}
|
|
116
|
+
onClick={onClick}
|
|
117
|
+
{...rest}
|
|
118
|
+
>
|
|
119
|
+
{children}
|
|
120
|
+
</button>
|
|
121
|
+
</>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Readable } from 'stream'
|
|
2
|
+
import { failResult } from '../errors/UploadError.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Scan a buffer with ClamAV using the optional `clamscan` peer dependency.
|
|
6
|
+
*
|
|
7
|
+
* Behaviour when ClamAV is unavailable:
|
|
8
|
+
* - clamscan not installed → warn + skip (upload proceeds)
|
|
9
|
+
* - ClamAV daemon not running → warn + skip (upload proceeds)
|
|
10
|
+
*
|
|
11
|
+
* @param {Buffer} buffer
|
|
12
|
+
* @param {object} [clamavOptions] - options forwarded to NodeClam.init()
|
|
13
|
+
* @returns {Promise<{ success: true, skipped?: true } | { success: false, error: string, message: string }>}
|
|
14
|
+
*/
|
|
15
|
+
export async function scanWithClamAV(buffer, clamavOptions = {}) {
|
|
16
|
+
let NodeClam
|
|
17
|
+
try {
|
|
18
|
+
const mod = await import('clamscan')
|
|
19
|
+
NodeClam = mod.default ?? mod
|
|
20
|
+
} catch {
|
|
21
|
+
console.warn(
|
|
22
|
+
'[fileguard] ClamAV scan skipped: clamscan package is not installed. ' +
|
|
23
|
+
'Install it with: npm install clamscan'
|
|
24
|
+
)
|
|
25
|
+
return { success: true, skipped: true }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let clam
|
|
29
|
+
try {
|
|
30
|
+
clam = await new NodeClam().init(clamavOptions)
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.warn(`[fileguard] ClamAV initialisation failed: ${err.message}. Skipping scan.`)
|
|
33
|
+
return { success: true, skipped: true }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const stream = Readable.from(buffer)
|
|
38
|
+
const { isInfected, viruses } = await clam.scanStream(stream)
|
|
39
|
+
|
|
40
|
+
if (isInfected) {
|
|
41
|
+
const names = Array.isArray(viruses) && viruses.length ? viruses.join(', ') : 'unknown'
|
|
42
|
+
return failResult('VIRUS_DETECTED', `ClamAV detected malware: ${names}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { success: true }
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.warn(`[fileguard] ClamAV scan error: ${err.message}. Skipping scan.`)
|
|
48
|
+
return { success: true, skipped: true }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { fileTypeFromBuffer } from 'file-type'
|
|
2
|
+
import { failResult } from '../errors/UploadError.js'
|
|
3
|
+
|
|
4
|
+
const READ_BYTES = 8192
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Maps allowed MIME types to their expected file-type detected MIME.
|
|
8
|
+
* Used to cross-check the detected type against what was declared.
|
|
9
|
+
*/
|
|
10
|
+
const MIME_ALIAS = {
|
|
11
|
+
'image/jpg': 'image/jpeg',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Detect the real file type from the buffer's magic bytes.
|
|
16
|
+
* Returns the detected { ext, mime } or null if unknown.
|
|
17
|
+
* @param {Buffer} buffer
|
|
18
|
+
* @returns {Promise<{ ext: string, mime: string } | null>}
|
|
19
|
+
*/
|
|
20
|
+
export async function detectFileType(buffer) {
|
|
21
|
+
try {
|
|
22
|
+
if (!buffer || buffer.length === 0) return null
|
|
23
|
+
const slice = buffer.subarray(0, READ_BYTES)
|
|
24
|
+
return (await fileTypeFromBuffer(slice)) ?? null
|
|
25
|
+
} catch {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validate that the file's magic bytes match an expected MIME type.
|
|
32
|
+
* @param {Buffer} buffer - Full file buffer
|
|
33
|
+
* @param {string} declaredMime - MIME type declared by the uploader
|
|
34
|
+
* @param {string[]} allowedMimeTypes - List of allowed MIME types from config
|
|
35
|
+
* @returns {Promise<{ success: true, detectedMime: string, detectedExt: string } | { success: false, error: string, message: string }>}
|
|
36
|
+
*/
|
|
37
|
+
export async function validateMagicBytes(buffer, declaredMime, allowedMimeTypes) {
|
|
38
|
+
const detected = await detectFileType(buffer)
|
|
39
|
+
|
|
40
|
+
if (!detected) {
|
|
41
|
+
return failResult('INVALID_MAGIC_BYTES', 'File type could not be determined from its contents')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const normalizedAllowed = allowedMimeTypes.map((m) => MIME_ALIAS[m] ?? m)
|
|
45
|
+
|
|
46
|
+
if (!normalizedAllowed.includes(detected.mime)) {
|
|
47
|
+
return failResult(
|
|
48
|
+
'INVALID_MAGIC_BYTES',
|
|
49
|
+
`File content does not match any allowed type (detected: ${detected.mime})`
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const normalizedDeclared = MIME_ALIAS[declaredMime] ?? declaredMime
|
|
54
|
+
if (normalizedDeclared !== detected.mime) {
|
|
55
|
+
return failResult(
|
|
56
|
+
'INVALID_MAGIC_BYTES',
|
|
57
|
+
`Declared MIME type "${declaredMime}" does not match file content (detected: ${detected.mime})`
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { success: true, detectedMime: detected.mime, detectedExt: detected.ext }
|
|
62
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { failResult } from '../errors/UploadError.js'
|
|
2
|
+
|
|
3
|
+
// Hex byte sequences searched from offset 32 onward.
|
|
4
|
+
// Matching any of these in a non-archive file indicates a polyglot.
|
|
5
|
+
const RED_FLAGS = [
|
|
6
|
+
{ hex: '4d5a', label: 'Windows PE/EXE (MZ header)' },
|
|
7
|
+
{ hex: '3c736372697074', label: 'embedded <script> tag' },
|
|
8
|
+
{ hex: '504b0304', label: 'nested ZIP archive' },
|
|
9
|
+
{ hex: '3c3f706870', label: 'embedded PHP code' },
|
|
10
|
+
{ hex: '2321', label: 'shell shebang (#!)' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
// Pre-convert hex patterns to Buffer instances once at module load.
|
|
14
|
+
const FLAG_BUFS = RED_FLAGS.map(({ hex, label }) => ({
|
|
15
|
+
buf: Buffer.from(hex, 'hex'),
|
|
16
|
+
label,
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
const SCAN_SKIP = 32
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Search for a needle Buffer inside a haystack Buffer starting at `fromOffset`.
|
|
23
|
+
* @param {Buffer} haystack
|
|
24
|
+
* @param {Buffer} needle
|
|
25
|
+
* @param {number} fromOffset
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
function includes(haystack, needle, fromOffset) {
|
|
29
|
+
const limit = haystack.length - needle.length
|
|
30
|
+
for (let i = fromOffset; i <= limit; i++) {
|
|
31
|
+
let match = true
|
|
32
|
+
for (let j = 0; j < needle.length; j++) {
|
|
33
|
+
if (haystack[i + j] !== needle[j]) { match = false; break }
|
|
34
|
+
}
|
|
35
|
+
if (match) return true
|
|
36
|
+
}
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Scan a file buffer for polyglot indicators from byte 32 onward.
|
|
42
|
+
* Files that embed executable or script content inside another format
|
|
43
|
+
* (e.g. JPEG with a hidden PE header) are rejected.
|
|
44
|
+
*
|
|
45
|
+
* @param {Buffer} buffer
|
|
46
|
+
* @returns {{ success: true } | { success: false, error: string, message: string }}
|
|
47
|
+
*/
|
|
48
|
+
export function checkPolyglot(buffer) {
|
|
49
|
+
if (buffer.length <= SCAN_SKIP) return { success: true }
|
|
50
|
+
|
|
51
|
+
for (const { buf, label } of FLAG_BUFS) {
|
|
52
|
+
if (includes(buffer, buf, SCAN_SKIP)) {
|
|
53
|
+
return failResult('POLYGLOT_DETECTED', `File contains suspicious embedded content: ${label}`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { success: true }
|
|
58
|
+
}
|