create-fluxstack 1.14.0 โ 1.15.0
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/LLMD/resources/live-components.md +207 -12
- package/app/client/.live-stubs/LiveAdminPanel.js +5 -0
- package/app/client/.live-stubs/LiveChat.js +7 -0
- package/app/client/.live-stubs/LiveCounter.js +9 -0
- package/app/client/.live-stubs/LiveForm.js +11 -0
- package/app/client/.live-stubs/LiveLocalCounter.js +8 -0
- package/app/client/.live-stubs/LiveRoomChat.js +10 -0
- package/app/client/.live-stubs/LiveTodoList.js +9 -0
- package/app/client/.live-stubs/LiveUpload.js +15 -0
- package/app/client/src/App.tsx +11 -0
- package/app/client/src/components/AppLayout.tsx +16 -8
- package/app/client/src/live/LiveDebuggerPanel.tsx +1 -1
- package/app/client/src/live/TodoListDemo.tsx +158 -0
- package/app/server/auth/DevAuthProvider.ts +2 -2
- package/app/server/auth/JWTAuthProvider.example.ts +2 -2
- package/app/server/index.ts +2 -2
- package/app/server/live/LiveAdminPanel.ts +1 -1
- package/app/server/live/LiveProtectedChat.ts +1 -1
- package/app/server/live/LiveTodoList.ts +110 -0
- package/app/server/routes/room.routes.ts +1 -2
- package/core/build/live-components-generator.ts +1 -1
- package/core/build/vite-plugins.ts +28 -0
- package/core/client/components/LiveDebugger.tsx +1 -1
- package/core/client/hooks/useLiveUpload.ts +3 -4
- package/core/client/index.ts +37 -31
- package/core/framework/server.ts +1 -1
- package/core/server/index.ts +1 -2
- package/core/server/live/auto-generated-components.ts +6 -3
- package/core/server/live/index.ts +95 -21
- package/core/server/live/websocket-plugin.ts +27 -1087
- package/core/types/types.ts +76 -1025
- package/core/utils/version.ts +1 -1
- package/create-fluxstack.ts +1 -1
- package/package.json +5 -1
- package/plugins/crypto-auth/index.ts +1 -1
- package/plugins/crypto-auth/server/CryptoAuthLiveProvider.ts +2 -2
- package/vite.config.ts +40 -12
- package/core/client/LiveComponentsProvider.tsx +0 -531
- package/core/client/components/Live.tsx +0 -111
- package/core/client/hooks/AdaptiveChunkSizer.ts +0 -215
- package/core/client/hooks/state-validator.ts +0 -130
- package/core/client/hooks/useChunkedUpload.ts +0 -359
- package/core/client/hooks/useLiveChunkedUpload.ts +0 -87
- package/core/client/hooks/useLiveComponent.ts +0 -853
- package/core/client/hooks/useLiveDebugger.ts +0 -392
- package/core/client/hooks/useRoom.ts +0 -409
- package/core/client/hooks/useRoomProxy.ts +0 -382
- package/core/server/live/ComponentRegistry.ts +0 -1128
- package/core/server/live/FileUploadManager.ts +0 -446
- package/core/server/live/LiveComponentPerformanceMonitor.ts +0 -931
- package/core/server/live/LiveDebugger.ts +0 -462
- package/core/server/live/LiveLogger.ts +0 -144
- package/core/server/live/LiveRoomManager.ts +0 -278
- package/core/server/live/RoomEventBus.ts +0 -234
- package/core/server/live/RoomStateManager.ts +0 -172
- package/core/server/live/SingleConnectionManager.ts +0 -0
- package/core/server/live/StateSignature.ts +0 -705
- package/core/server/live/WebSocketConnectionManager.ts +0 -710
- package/core/server/live/auth/LiveAuthContext.ts +0 -71
- package/core/server/live/auth/LiveAuthManager.ts +0 -304
- package/core/server/live/auth/index.ts +0 -19
- package/core/server/live/auth/types.ts +0 -179
|
@@ -1,446 +0,0 @@
|
|
|
1
|
-
import { writeFile, mkdir, unlink } from 'fs/promises'
|
|
2
|
-
import { existsSync } from 'fs'
|
|
3
|
-
import { join, extname, basename } from 'path'
|
|
4
|
-
import { liveLog, liveWarn } from './LiveLogger'
|
|
5
|
-
import type {
|
|
6
|
-
ActiveUpload,
|
|
7
|
-
FileUploadStartMessage,
|
|
8
|
-
FileUploadChunkMessage,
|
|
9
|
-
FileUploadCompleteMessage,
|
|
10
|
-
FileUploadProgressResponse,
|
|
11
|
-
FileUploadCompleteResponse
|
|
12
|
-
} from '@core/types/types'
|
|
13
|
-
|
|
14
|
-
// ๐ Magic bytes mapping for content validation
|
|
15
|
-
// Validates actual file content, not just the MIME type header
|
|
16
|
-
const MAGIC_BYTES: Record<string, { bytes: number[]; offset?: number }[]> = {
|
|
17
|
-
'image/jpeg': [{ bytes: [0xFF, 0xD8, 0xFF] }],
|
|
18
|
-
'image/png': [{ bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] }],
|
|
19
|
-
'image/gif': [
|
|
20
|
-
{ bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61] }, // GIF87a
|
|
21
|
-
{ bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61] }, // GIF89a
|
|
22
|
-
],
|
|
23
|
-
'image/webp': [
|
|
24
|
-
{ bytes: [0x52, 0x49, 0x46, 0x46], offset: 0 }, // RIFF header
|
|
25
|
-
// Byte 8-11 should be WEBP, checked separately
|
|
26
|
-
],
|
|
27
|
-
'application/pdf': [{ bytes: [0x25, 0x50, 0x44, 0x46] }], // %PDF
|
|
28
|
-
'application/zip': [
|
|
29
|
-
{ bytes: [0x50, 0x4B, 0x03, 0x04] }, // PK\x03\x04
|
|
30
|
-
{ bytes: [0x50, 0x4B, 0x05, 0x06] }, // Empty archive
|
|
31
|
-
],
|
|
32
|
-
'application/gzip': [{ bytes: [0x1F, 0x8B] }],
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export class FileUploadManager {
|
|
36
|
-
private activeUploads = new Map<string, ActiveUpload>()
|
|
37
|
-
private readonly maxUploadSize = 50 * 1024 * 1024 // ๐ 50MB max (reduced from 500MB)
|
|
38
|
-
private readonly chunkTimeout = 30000 // 30 seconds timeout per chunk
|
|
39
|
-
// ๐ Per-user upload quota tracking
|
|
40
|
-
private userUploadBytes = new Map<string, number>() // userId -> total bytes uploaded
|
|
41
|
-
private readonly maxBytesPerUser = 500 * 1024 * 1024 // ๐ 500MB per user total
|
|
42
|
-
private readonly quotaResetInterval = 24 * 60 * 60 * 1000 // Reset quotas daily
|
|
43
|
-
// ๐ Default allowed MIME types - safe file types only
|
|
44
|
-
private readonly allowedTypes: string[] = [
|
|
45
|
-
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
|
46
|
-
'application/pdf',
|
|
47
|
-
'text/plain', 'text/csv', 'text/markdown',
|
|
48
|
-
'application/json',
|
|
49
|
-
'application/zip', 'application/gzip',
|
|
50
|
-
]
|
|
51
|
-
// ๐ Blocked file extensions that could be dangerous
|
|
52
|
-
private readonly blockedExtensions: Set<string> = new Set([
|
|
53
|
-
'.exe', '.bat', '.cmd', '.com', '.msi', '.scr', '.pif',
|
|
54
|
-
'.sh', '.bash', '.zsh', '.csh',
|
|
55
|
-
'.ps1', '.psm1', '.psd1',
|
|
56
|
-
'.vbs', '.vbe', '.js', '.jse', '.wsf', '.wsh',
|
|
57
|
-
'.dll', '.sys', '.drv', '.so', '.dylib',
|
|
58
|
-
])
|
|
59
|
-
|
|
60
|
-
constructor() {
|
|
61
|
-
// Cleanup stale uploads every 5 minutes
|
|
62
|
-
setInterval(() => this.cleanupStaleUploads(), 5 * 60 * 1000)
|
|
63
|
-
// ๐ Reset per-user upload quotas daily
|
|
64
|
-
setInterval(() => this.resetUploadQuotas(), this.quotaResetInterval)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async startUpload(message: FileUploadStartMessage, userId?: string): Promise<{ success: boolean; error?: string }> {
|
|
68
|
-
try {
|
|
69
|
-
const { uploadId, componentId, filename, fileType, fileSize, chunkSize = 64 * 1024 } = message
|
|
70
|
-
|
|
71
|
-
// ๐ Validate file size
|
|
72
|
-
if (fileSize > this.maxUploadSize) {
|
|
73
|
-
throw new Error(`File too large: ${fileSize} bytes. Max: ${this.maxUploadSize} bytes`)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ๐ Per-user upload quota check
|
|
77
|
-
if (userId) {
|
|
78
|
-
const currentUsage = this.userUploadBytes.get(userId) || 0
|
|
79
|
-
if (currentUsage + fileSize > this.maxBytesPerUser) {
|
|
80
|
-
throw new Error(`Upload quota exceeded for user. Used: ${currentUsage} bytes, limit: ${this.maxBytesPerUser} bytes`)
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// ๐ Validate MIME type against allowlist
|
|
85
|
-
if (this.allowedTypes.length > 0 && !this.allowedTypes.includes(fileType)) {
|
|
86
|
-
throw new Error(`File type not allowed: ${fileType}`)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// ๐ Validate filename - sanitize and check extension
|
|
90
|
-
const safeBase = basename(filename) // Strip any path traversal
|
|
91
|
-
const ext = extname(safeBase).toLowerCase()
|
|
92
|
-
if (this.blockedExtensions.has(ext)) {
|
|
93
|
-
throw new Error(`File extension not allowed: ${ext}`)
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ๐ Double extension bypass prevention (e.g., malware.exe.jpg)
|
|
97
|
-
const parts = safeBase.split('.')
|
|
98
|
-
if (parts.length > 2) {
|
|
99
|
-
// Check all intermediate extensions
|
|
100
|
-
for (let i = 1; i < parts.length - 1; i++) {
|
|
101
|
-
const intermediateExt = '.' + parts[i].toLowerCase()
|
|
102
|
-
if (this.blockedExtensions.has(intermediateExt)) {
|
|
103
|
-
throw new Error(`Suspicious double extension detected: ${intermediateExt} in ${safeBase}`)
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// ๐ Validate filename length
|
|
109
|
-
if (safeBase.length > 255) {
|
|
110
|
-
throw new Error('Filename too long')
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Check if upload already exists
|
|
114
|
-
if (this.activeUploads.has(uploadId)) {
|
|
115
|
-
throw new Error(`Upload ${uploadId} already in progress`)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Calculate total chunks
|
|
119
|
-
const totalChunks = Math.ceil(fileSize / chunkSize)
|
|
120
|
-
|
|
121
|
-
// Create upload record
|
|
122
|
-
const upload: ActiveUpload = {
|
|
123
|
-
uploadId,
|
|
124
|
-
componentId,
|
|
125
|
-
filename,
|
|
126
|
-
fileType,
|
|
127
|
-
fileSize,
|
|
128
|
-
totalChunks,
|
|
129
|
-
receivedChunks: new Map(),
|
|
130
|
-
bytesReceived: 0, // Track actual bytes for adaptive chunking
|
|
131
|
-
startTime: Date.now(),
|
|
132
|
-
lastChunkTime: Date.now()
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
this.activeUploads.set(uploadId, upload)
|
|
136
|
-
|
|
137
|
-
// ๐ Reserve quota for this upload
|
|
138
|
-
if (userId) {
|
|
139
|
-
const currentUsage = this.userUploadBytes.get(userId) || 0
|
|
140
|
-
this.userUploadBytes.set(userId, currentUsage + fileSize)
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
liveLog('messages', componentId, '๐ค Upload started:', {
|
|
144
|
-
uploadId,
|
|
145
|
-
componentId,
|
|
146
|
-
filename,
|
|
147
|
-
fileType,
|
|
148
|
-
fileSize,
|
|
149
|
-
totalChunks,
|
|
150
|
-
userId: userId || 'anonymous'
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
return { success: true }
|
|
154
|
-
|
|
155
|
-
} catch (error: any) {
|
|
156
|
-
console.error('โ Upload start failed:', error.message)
|
|
157
|
-
return { success: false, error: error.message }
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
async receiveChunk(message: FileUploadChunkMessage, ws: any, binaryData: Buffer | null = null): Promise<FileUploadProgressResponse | null> {
|
|
162
|
-
try {
|
|
163
|
-
const { uploadId, chunkIndex, totalChunks, data } = message
|
|
164
|
-
|
|
165
|
-
const upload = this.activeUploads.get(uploadId)
|
|
166
|
-
if (!upload) {
|
|
167
|
-
throw new Error(`Upload ${uploadId} not found`)
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Validate chunk index
|
|
171
|
-
if (chunkIndex < 0 || chunkIndex >= totalChunks) {
|
|
172
|
-
throw new Error(`Invalid chunk index: ${chunkIndex}`)
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Check if chunk already received
|
|
176
|
-
if (upload.receivedChunks.has(chunkIndex)) {
|
|
177
|
-
liveLog('messages', upload.componentId, `๐ฆ Chunk ${chunkIndex} already received for upload ${uploadId}`)
|
|
178
|
-
} else {
|
|
179
|
-
// Store chunk data - use binary data if available, otherwise use base64 string
|
|
180
|
-
let chunkBytes: number
|
|
181
|
-
|
|
182
|
-
if (binaryData) {
|
|
183
|
-
// Binary protocol: store Buffer directly (more efficient)
|
|
184
|
-
upload.receivedChunks.set(chunkIndex, binaryData)
|
|
185
|
-
chunkBytes = binaryData.length
|
|
186
|
-
} else {
|
|
187
|
-
// JSON protocol: store base64 string (legacy support)
|
|
188
|
-
upload.receivedChunks.set(chunkIndex, data as string)
|
|
189
|
-
chunkBytes = Buffer.from(data as string, 'base64').length
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
upload.lastChunkTime = Date.now()
|
|
193
|
-
upload.bytesReceived += chunkBytes
|
|
194
|
-
|
|
195
|
-
liveLog('messages', upload.componentId, `๐ฆ Received chunk ${chunkIndex + 1}/${totalChunks} for upload ${uploadId} (${chunkBytes} bytes, total: ${upload.bytesReceived}/${upload.fileSize})${binaryData ? ' [binary]' : ' [base64]'}`)
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Calculate progress based on actual bytes received (supports adaptive chunking)
|
|
199
|
-
const progress = (upload.bytesReceived / upload.fileSize) * 100
|
|
200
|
-
const bytesUploaded = upload.bytesReceived
|
|
201
|
-
|
|
202
|
-
// Log completion status (but don't finalize until COMPLETE message)
|
|
203
|
-
if (upload.bytesReceived >= upload.fileSize) {
|
|
204
|
-
liveLog('messages', upload.componentId, `โ
All bytes received for upload ${uploadId} (${upload.bytesReceived}/${upload.fileSize}), waiting for COMPLETE message`)
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
return {
|
|
208
|
-
type: 'FILE_UPLOAD_PROGRESS',
|
|
209
|
-
componentId: upload.componentId,
|
|
210
|
-
uploadId: upload.uploadId,
|
|
211
|
-
chunkIndex,
|
|
212
|
-
totalChunks,
|
|
213
|
-
bytesUploaded: Math.min(bytesUploaded, upload.fileSize),
|
|
214
|
-
totalBytes: upload.fileSize,
|
|
215
|
-
progress: Math.min(progress, 100),
|
|
216
|
-
timestamp: Date.now()
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
} catch (error: any) {
|
|
220
|
-
console.error(`โ Chunk receive failed for upload ${message.uploadId}:`, error.message)
|
|
221
|
-
throw error
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
private async finalizeUpload(upload: ActiveUpload): Promise<void> {
|
|
226
|
-
try {
|
|
227
|
-
liveLog('messages', upload.componentId, `โ
Upload completed: ${upload.uploadId}`)
|
|
228
|
-
|
|
229
|
-
// Assemble file from chunks
|
|
230
|
-
const fileUrl = await this.assembleFile(upload)
|
|
231
|
-
|
|
232
|
-
// Cleanup
|
|
233
|
-
this.activeUploads.delete(upload.uploadId)
|
|
234
|
-
|
|
235
|
-
} catch (error: any) {
|
|
236
|
-
console.error(`โ Upload finalization failed for ${upload.uploadId}:`, error.message)
|
|
237
|
-
throw error
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
async completeUpload(message: FileUploadCompleteMessage): Promise<FileUploadCompleteResponse> {
|
|
242
|
-
try {
|
|
243
|
-
const { uploadId } = message
|
|
244
|
-
|
|
245
|
-
const upload = this.activeUploads.get(uploadId)
|
|
246
|
-
if (!upload) {
|
|
247
|
-
throw new Error(`Upload ${uploadId} not found`)
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
liveLog('messages', upload.componentId, `โ
Upload completion requested: ${uploadId}`)
|
|
251
|
-
|
|
252
|
-
// Validate bytes received (supports adaptive chunking)
|
|
253
|
-
if (upload.bytesReceived !== upload.fileSize) {
|
|
254
|
-
const bytesShort = upload.fileSize - upload.bytesReceived
|
|
255
|
-
throw new Error(`Incomplete upload: received ${upload.bytesReceived}/${upload.fileSize} bytes (${bytesShort} bytes short)`)
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// ๐ Content validation: verify file magic bytes match claimed MIME type
|
|
259
|
-
this.validateContentMagicBytes(upload)
|
|
260
|
-
|
|
261
|
-
liveLog('messages', upload.componentId, `โ
Upload validation passed: ${uploadId} (${upload.bytesReceived} bytes)`)
|
|
262
|
-
|
|
263
|
-
// Assemble file from chunks
|
|
264
|
-
const fileUrl = await this.assembleFile(upload)
|
|
265
|
-
|
|
266
|
-
// Cleanup
|
|
267
|
-
this.activeUploads.delete(uploadId)
|
|
268
|
-
|
|
269
|
-
return {
|
|
270
|
-
type: 'FILE_UPLOAD_COMPLETE',
|
|
271
|
-
componentId: upload.componentId,
|
|
272
|
-
uploadId: upload.uploadId,
|
|
273
|
-
success: true,
|
|
274
|
-
filename: upload.filename,
|
|
275
|
-
fileUrl,
|
|
276
|
-
timestamp: Date.now()
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
} catch (error: any) {
|
|
280
|
-
console.error(`โ Upload completion failed for ${message.uploadId}:`, error.message)
|
|
281
|
-
|
|
282
|
-
return {
|
|
283
|
-
type: 'FILE_UPLOAD_COMPLETE',
|
|
284
|
-
componentId: '',
|
|
285
|
-
uploadId: message.uploadId,
|
|
286
|
-
success: false,
|
|
287
|
-
error: error.message,
|
|
288
|
-
timestamp: Date.now()
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
private async assembleFile(upload: ActiveUpload): Promise<string> {
|
|
294
|
-
try {
|
|
295
|
-
// Create uploads directory if it doesn't exist
|
|
296
|
-
const uploadsDir = './uploads'
|
|
297
|
-
if (!existsSync(uploadsDir)) {
|
|
298
|
-
await mkdir(uploadsDir, { recursive: true })
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// ๐ Generate secure unique filename using UUID (prevents path traversal and name collisions)
|
|
302
|
-
const extension = extname(basename(upload.filename)).toLowerCase()
|
|
303
|
-
const safeFilename = `${crypto.randomUUID()}${extension}`
|
|
304
|
-
const filePath = join(uploadsDir, safeFilename)
|
|
305
|
-
|
|
306
|
-
// Assemble chunks in order
|
|
307
|
-
const chunks: Buffer[] = []
|
|
308
|
-
for (let i = 0; i < upload.totalChunks; i++) {
|
|
309
|
-
const chunkData = upload.receivedChunks.get(i)
|
|
310
|
-
if (chunkData) {
|
|
311
|
-
// Handle both Buffer (binary protocol) and string (base64 JSON protocol)
|
|
312
|
-
if (Buffer.isBuffer(chunkData)) {
|
|
313
|
-
chunks.push(chunkData)
|
|
314
|
-
} else {
|
|
315
|
-
chunks.push(Buffer.from(chunkData, 'base64'))
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// Write assembled file
|
|
321
|
-
const fileBuffer = Buffer.concat(chunks)
|
|
322
|
-
await writeFile(filePath, fileBuffer)
|
|
323
|
-
|
|
324
|
-
liveLog('messages', upload.componentId, `๐ File assembled: ${filePath}`)
|
|
325
|
-
return `/uploads/${safeFilename}`
|
|
326
|
-
|
|
327
|
-
} catch (error) {
|
|
328
|
-
console.error('โ File assembly failed:', error)
|
|
329
|
-
throw error
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
private cleanupStaleUploads(): void {
|
|
334
|
-
const now = Date.now()
|
|
335
|
-
const staleUploads: string[] = []
|
|
336
|
-
|
|
337
|
-
for (const [uploadId, upload] of this.activeUploads) {
|
|
338
|
-
const timeSinceLastChunk = now - upload.lastChunkTime
|
|
339
|
-
|
|
340
|
-
if (timeSinceLastChunk > this.chunkTimeout * 2) {
|
|
341
|
-
staleUploads.push(uploadId)
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
for (const uploadId of staleUploads) {
|
|
346
|
-
this.activeUploads.delete(uploadId)
|
|
347
|
-
liveLog('messages', null, `๐งน Cleaned up stale upload: ${uploadId}`)
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
if (staleUploads.length > 0) {
|
|
351
|
-
liveLog('messages', null, `๐งน Cleaned up ${staleUploads.length} stale uploads`)
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* ๐ Validate that the first bytes of the uploaded file match the claimed MIME type.
|
|
357
|
-
* Prevents attacks where a malicious file is uploaded with a fake MIME type header.
|
|
358
|
-
*/
|
|
359
|
-
private validateContentMagicBytes(upload: ActiveUpload): void {
|
|
360
|
-
const expectedSignatures = MAGIC_BYTES[upload.fileType]
|
|
361
|
-
if (!expectedSignatures) {
|
|
362
|
-
// No magic bytes defined for this type (text types, SVG, JSON, etc.) - skip binary check
|
|
363
|
-
// For text types, we could add content sniffing but it's less critical
|
|
364
|
-
return
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// Get the first chunk to read magic bytes
|
|
368
|
-
const firstChunk = upload.receivedChunks.get(0)
|
|
369
|
-
if (!firstChunk) {
|
|
370
|
-
throw new Error('Cannot validate file content: first chunk missing')
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const headerBuffer = Buffer.isBuffer(firstChunk)
|
|
374
|
-
? firstChunk
|
|
375
|
-
: Buffer.from(firstChunk, 'base64')
|
|
376
|
-
|
|
377
|
-
// Check if any of the expected signatures match
|
|
378
|
-
let matched = false
|
|
379
|
-
for (const sig of expectedSignatures) {
|
|
380
|
-
const offset = sig.offset ?? 0
|
|
381
|
-
if (headerBuffer.length < offset + sig.bytes.length) {
|
|
382
|
-
continue // File too small for this signature
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
let sigMatches = true
|
|
386
|
-
for (let i = 0; i < sig.bytes.length; i++) {
|
|
387
|
-
if (headerBuffer[offset + i] !== sig.bytes[i]) {
|
|
388
|
-
sigMatches = false
|
|
389
|
-
break
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
if (sigMatches) {
|
|
394
|
-
matched = true
|
|
395
|
-
break
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
if (!matched) {
|
|
400
|
-
liveWarn('messages', upload.componentId, `๐ Content validation failed for upload ${upload.uploadId}: ` +
|
|
401
|
-
`claimed type ${upload.fileType} does not match file magic bytes`)
|
|
402
|
-
throw new Error(
|
|
403
|
-
`File content does not match claimed type '${upload.fileType}'. ` +
|
|
404
|
-
`The file may be disguised as a different format.`
|
|
405
|
-
)
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* ๐ Reset per-user upload quotas (called periodically)
|
|
411
|
-
*/
|
|
412
|
-
private resetUploadQuotas(): void {
|
|
413
|
-
const userCount = this.userUploadBytes.size
|
|
414
|
-
this.userUploadBytes.clear()
|
|
415
|
-
if (userCount > 0) {
|
|
416
|
-
liveLog('messages', null, `๐ Reset upload quotas for ${userCount} users`)
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/**
|
|
421
|
-
* Get per-user upload usage
|
|
422
|
-
*/
|
|
423
|
-
getUserUploadUsage(userId: string): { used: number; limit: number; remaining: number } {
|
|
424
|
-
const used = this.userUploadBytes.get(userId) || 0
|
|
425
|
-
return {
|
|
426
|
-
used,
|
|
427
|
-
limit: this.maxBytesPerUser,
|
|
428
|
-
remaining: Math.max(0, this.maxBytesPerUser - used)
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
getUploadStatus(uploadId: string): ActiveUpload | null {
|
|
433
|
-
return this.activeUploads.get(uploadId) || null
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
getStats() {
|
|
437
|
-
return {
|
|
438
|
-
activeUploads: this.activeUploads.size,
|
|
439
|
-
maxUploadSize: this.maxUploadSize,
|
|
440
|
-
allowedTypes: this.allowedTypes
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// Global instance
|
|
446
|
-
export const fileUploadManager = new FileUploadManager()
|