@leogps/file-uploader 2.0.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/.eslintrc.js +178 -0
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/dist/client/1551f4f60c37af51121f.woff2 +0 -0
- package/dist/client/2285773e6b4b172f07d9.woff +0 -0
- package/dist/client/23f19bb08961f37aaf69.eot +0 -0
- package/dist/client/2f517e09eb2ca6650ff5.svg +3717 -0
- package/dist/client/4689f52cc96215721344.svg +801 -0
- package/dist/client/491974d108fe4002b2aa.ttf +0 -0
- package/dist/client/527940b104eb2ea366c8.ttf +0 -0
- package/dist/client/77206a6bb316fa0aded5.eot +0 -0
- package/dist/client/7a3337626410ca2f4071.woff2 +0 -0
- package/dist/client/7a8b4f130182d19a2d7c.svg +5034 -0
- package/dist/client/9bbb245e67a133f6e486.eot +0 -0
- package/dist/client/bb58e57c48a3e911f15f.woff +0 -0
- package/dist/client/be9ee23c0c6390141475.ttf +0 -0
- package/dist/client/d878b0a6a1144760244f.woff2 +0 -0
- package/dist/client/eeccf4f66002c6f2ba24.woff +0 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/index.html +1 -0
- package/dist/client/main.66a16cbe5e2ce036e9a7.bundle.js +39507 -0
- package/dist/client/main.6db272040eaab1c51019.css +14 -0
- package/dist/index.js +3 -0
- package/dist/index.js.LICENSE.txt +273 -0
- package/package-gzip.js +30 -0
- package/package.json +107 -0
- package/src/globals.ts +23 -0
- package/src/index.ts +87 -0
- package/src/model/progress.ts +175 -0
- package/src/model/progress_utils.ts +17 -0
- package/src/routes/uploadChunk.ts +125 -0
- package/src/routes/uploadComplete.ts +53 -0
- package/src/routes/uploadInit.ts +83 -0
- package/src/routes/uploadStatus.ts +137 -0
- package/src/service/progress_writer.ts +52 -0
- package/src-client/entrypoint.ts +273 -0
- package/src-client/progress-handler.ts +233 -0
- package/src-client/public/favicon.ico +0 -0
- package/src-client/public/index.html +67 -0
- package/src-client/sha1.ts +19 -0
- package/src-client/style.scss +87 -0
- package/tsconfig.json +107 -0
- package/webpack-client.common.js +29 -0
- package/webpack-client.dev.js +51 -0
- package/webpack-client.prod.js +65 -0
- package/webpack.config.js +41 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {Progress} from './progress';
|
|
2
|
+
|
|
3
|
+
export class ProgressUtils {
|
|
4
|
+
public static calculateTransferRate(progress: Progress): number {
|
|
5
|
+
const transferSamples = progress.transferSamples;
|
|
6
|
+
if (!transferSamples || transferSamples.length < 2) {
|
|
7
|
+
return 0;
|
|
8
|
+
}
|
|
9
|
+
const first = transferSamples[0];
|
|
10
|
+
const last = transferSamples[transferSamples.length - 1];
|
|
11
|
+
|
|
12
|
+
const dataSize = last.bytesReceived - first.bytesReceived;
|
|
13
|
+
const timeIntervalSeconds = (last.timestamp - first.timestamp) / 1000;
|
|
14
|
+
|
|
15
|
+
return dataSize / timeIntervalSeconds;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Router, Request, Response } from "express";
|
|
2
|
+
import formidable, { File } from "formidable";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import crypto from "crypto";
|
|
6
|
+
import {
|
|
7
|
+
uploadsProgressMap,
|
|
8
|
+
getUploadsDir,
|
|
9
|
+
throttledBroadcaster
|
|
10
|
+
} from "../globals";
|
|
11
|
+
import { FileTransferProgress } from "../model/progress";
|
|
12
|
+
|
|
13
|
+
export const router = Router();
|
|
14
|
+
|
|
15
|
+
router.post("/", (req: Request, res: Response) => {
|
|
16
|
+
(async () => {
|
|
17
|
+
const fileId = req.query.fileId as string;
|
|
18
|
+
const chunkIndex = Number(req.query.chunkIndex);
|
|
19
|
+
const clientHash = req.query.hash as string;
|
|
20
|
+
|
|
21
|
+
if (!fileId || !uploadsProgressMap.has(fileId)) {
|
|
22
|
+
return res.status(400).json({ msg: "Invalid or unknown fileId" });
|
|
23
|
+
}
|
|
24
|
+
if (isNaN(chunkIndex) || chunkIndex < 0) {
|
|
25
|
+
return res.status(400).json({ msg: "Invalid chunk index" });
|
|
26
|
+
}
|
|
27
|
+
if (!clientHash) {
|
|
28
|
+
return res.status(400).json({ msg: "Missing SHA-1 hash for chunk" });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const progress = uploadsProgressMap.get(fileId)! as FileTransferProgress;
|
|
32
|
+
|
|
33
|
+
const uploadDir = getUploadsDir();
|
|
34
|
+
await fs.mkdir(uploadDir, { recursive: true });
|
|
35
|
+
|
|
36
|
+
const form = formidable({
|
|
37
|
+
multiples: false,
|
|
38
|
+
keepExtensions: true,
|
|
39
|
+
uploadDir
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
let receivedFile: File | null = null;
|
|
43
|
+
let errorDuringFileWrite: any = null;
|
|
44
|
+
|
|
45
|
+
form.on("file", (_: string, file: File) => {
|
|
46
|
+
(async () => {
|
|
47
|
+
receivedFile = file;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const finalPath = path.join(uploadDir, progress.fileName!);
|
|
51
|
+
|
|
52
|
+
// Sparse file creation
|
|
53
|
+
try {
|
|
54
|
+
await fs.access(finalPath);
|
|
55
|
+
} catch {
|
|
56
|
+
const fh = await fs.open(finalPath, "w");
|
|
57
|
+
await fh.truncate(progress.bytesExpected);
|
|
58
|
+
await fh.close();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const chunkBuffer = await fs.readFile(file.filepath);
|
|
62
|
+
const offset = chunkIndex * progress.chunkSize!;
|
|
63
|
+
|
|
64
|
+
const fd = await fs.open(finalPath, "r+");
|
|
65
|
+
await fd.write(chunkBuffer, 0, chunkBuffer.length, offset);
|
|
66
|
+
|
|
67
|
+
const verifyBuffer = Buffer.alloc(chunkBuffer.length);
|
|
68
|
+
await fd.read(verifyBuffer, 0, chunkBuffer.length, offset);
|
|
69
|
+
await fd.close();
|
|
70
|
+
|
|
71
|
+
const hash = crypto.createHash("sha1").update(verifyBuffer).digest("hex");
|
|
72
|
+
if (hash !== clientHash) {
|
|
73
|
+
errorDuringFileWrite = {
|
|
74
|
+
status: 400,
|
|
75
|
+
body: {
|
|
76
|
+
msg: "Chunk hash mismatch",
|
|
77
|
+
expected: clientHash,
|
|
78
|
+
got: hash
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
} else {
|
|
82
|
+
progress.uploadingChunks.delete(chunkIndex);
|
|
83
|
+
progress.addUploadedChunk(chunkIndex);
|
|
84
|
+
progress.markSample();
|
|
85
|
+
}
|
|
86
|
+
throttledBroadcaster();
|
|
87
|
+
|
|
88
|
+
await fs.unlink(file.filepath);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error("Chunk write error:", err);
|
|
91
|
+
errorDuringFileWrite = {
|
|
92
|
+
status: 500,
|
|
93
|
+
body: { msg: "Internal write error", error: err }
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
})()
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
form.parse(req, (err) => {
|
|
100
|
+
if (err) {
|
|
101
|
+
console.error("Form parse error:", err);
|
|
102
|
+
return res.status(500).json({ msg: "Error receiving chunk", error: err });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (errorDuringFileWrite) {
|
|
106
|
+
return res.status(errorDuringFileWrite.status).json(errorDuringFileWrite.body);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!receivedFile) {
|
|
110
|
+
return res.status(400).json({ msg: "No chunk received" });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
console.log(`uploaded-chunk: ${chunkIndex} for ${progress.fileName}`);
|
|
114
|
+
return res.json({
|
|
115
|
+
msg: "Chunk uploaded successfully",
|
|
116
|
+
fileId,
|
|
117
|
+
chunkIndex,
|
|
118
|
+
hashMatches: true,
|
|
119
|
+
bytesReceived: progress.bytesReceived,
|
|
120
|
+
bytesExpected: progress.bytesExpected,
|
|
121
|
+
uploadedChunks: Array.from(progress.uploadedChunks)
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
})()
|
|
125
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {Request, Response, Router} from 'express';
|
|
2
|
+
import {getProgressWriter, progresses, uploadsProgressMap} from '../globals';
|
|
3
|
+
import {FileTransferProgress, UploadStatus} from "../model/progress";
|
|
4
|
+
|
|
5
|
+
export const router = Router();
|
|
6
|
+
|
|
7
|
+
// POST /upload/complete?fileId=UUID
|
|
8
|
+
router.post('/', (req: Request, res: Response) => {
|
|
9
|
+
const fileId = req.query.fileId as string;
|
|
10
|
+
const markUploadFailed: boolean = req.query.markUploadFailed === "true";
|
|
11
|
+
|
|
12
|
+
if (!fileId || !uploadsProgressMap.has(fileId)) {
|
|
13
|
+
return res.status(400).json({ msg: 'Invalid or unknown fileId' });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const progress: FileTransferProgress = uploadsProgressMap.get(fileId)! as FileTransferProgress;
|
|
17
|
+
progress.lastState = UploadStatus.FINISHING;
|
|
18
|
+
|
|
19
|
+
// Ensure uploadedChunks and totalChunks exist
|
|
20
|
+
if (!progress.uploadedChunks) {
|
|
21
|
+
progress.resetUploadedChunks();
|
|
22
|
+
}
|
|
23
|
+
if (!progress.totalChunks && progress.chunkSize && progress.bytesExpected) {
|
|
24
|
+
progress.totalChunks = Math.ceil(progress.bytesExpected / progress.chunkSize);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Check all chunks uploaded
|
|
28
|
+
if (progress.uploadedChunks.size !== progress.totalChunks) {
|
|
29
|
+
if (markUploadFailed) {
|
|
30
|
+
console.log(`Marking upload failed for file ${progress.fileName} ${progress.uuid}`);
|
|
31
|
+
progress.lastState = UploadStatus.FAILED;
|
|
32
|
+
}
|
|
33
|
+
getProgressWriter().writeProgress(progresses);
|
|
34
|
+
return res.status(400).json({
|
|
35
|
+
msg: 'File incomplete',
|
|
36
|
+
uploadedChunks: Array.from(progress.uploadedChunks),
|
|
37
|
+
totalChunks: progress.totalChunks
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
progress.completed = Date.now();
|
|
42
|
+
progress.lastState = UploadStatus.COMPLETE;
|
|
43
|
+
getProgressWriter().writeProgress(progresses);
|
|
44
|
+
|
|
45
|
+
res.json({
|
|
46
|
+
msg: 'File upload complete',
|
|
47
|
+
fileName: progress.fileName,
|
|
48
|
+
savedLocation: progress.savedLocation,
|
|
49
|
+
bytesReceived: progress.bytesReceived,
|
|
50
|
+
totalChunks: progress.totalChunks,
|
|
51
|
+
chunkSize: progress.chunkSize
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Router, Request, Response } from 'express';
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
import {uploadsProgressMap, progresses, getUploadsDir, MAX_PARALLEL_CHUNK_UPLOADS, MAX_CHUNK_SIZE} from '../globals';
|
|
4
|
+
import { FileTransferProgress } from '../model/progress';
|
|
5
|
+
import path from "path";
|
|
6
|
+
import fs from "fs";
|
|
7
|
+
|
|
8
|
+
export const router = Router();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* POST /upload/init
|
|
12
|
+
* Accepts fileName and fileSize either in JSON body or query parameters.
|
|
13
|
+
* Returns a fileId to be used for chunked uploads.
|
|
14
|
+
*/
|
|
15
|
+
router.post('/', (req: Request, res: Response) => {
|
|
16
|
+
console.log("Req body: " + JSON.stringify(req.body));
|
|
17
|
+
let fileName: string | undefined = req.body?.fileName;
|
|
18
|
+
let fileSize: number | undefined = req.body?.fileSize;
|
|
19
|
+
|
|
20
|
+
// Fallback to query params
|
|
21
|
+
if (!fileName || !fileSize) {
|
|
22
|
+
fileName = req.query.fileName as string;
|
|
23
|
+
fileSize = parseInt(req.query.fileSize as string, 10);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
console.log("File name: " + fileName);
|
|
27
|
+
console.log("File size: " + fileSize);
|
|
28
|
+
if (!fileName || !fileSize || isNaN(fileSize)) {
|
|
29
|
+
return res.status(400).json({ msg: 'Missing or invalid fileName/fileSize' });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const finalPath = path.join(getUploadsDir(), fileName);
|
|
33
|
+
let progress: FileTransferProgress;
|
|
34
|
+
let fileId: string;
|
|
35
|
+
|
|
36
|
+
// Check if file already exists
|
|
37
|
+
if (fs.existsSync(finalPath)) {
|
|
38
|
+
// Try to find existing progress in memory
|
|
39
|
+
const existingProgress = Array.from(uploadsProgressMap.values())
|
|
40
|
+
.find(p => p.fileName === fileName && p.bytesExpected === fileSize) as FileTransferProgress | undefined;
|
|
41
|
+
|
|
42
|
+
if (existingProgress) {
|
|
43
|
+
progress = existingProgress;
|
|
44
|
+
fileId = progress.uuid!;
|
|
45
|
+
} else {
|
|
46
|
+
// File exists but no memory entry (server restarted)
|
|
47
|
+
fileId = uuidv4();
|
|
48
|
+
progress = new FileTransferProgress(fileId, Date.now());
|
|
49
|
+
progress.fileName = fileName;
|
|
50
|
+
progress.bytesExpected = fileSize;
|
|
51
|
+
progress.chunkSize = 5 * 1024 * 1024;
|
|
52
|
+
progress.totalChunks = Math.ceil(fileSize / progress.chunkSize);
|
|
53
|
+
progress.bytesReceived = fs.statSync(finalPath).size; // resume
|
|
54
|
+
progress.resetUploadedChunks();
|
|
55
|
+
uploadsProgressMap.set(fileId, progress);
|
|
56
|
+
progresses.push(progress);
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
// File does not exist, create new progress
|
|
60
|
+
console.log(`creating file ${finalPath}`);
|
|
61
|
+
fs.writeFileSync(finalPath, Buffer.alloc(0));
|
|
62
|
+
fileId = uuidv4();
|
|
63
|
+
progress = new FileTransferProgress(fileId, Date.now());
|
|
64
|
+
progress.fileName = fileName;
|
|
65
|
+
progress.bytesExpected = fileSize;
|
|
66
|
+
progress.chunkSize = MAX_CHUNK_SIZE;
|
|
67
|
+
progress.totalChunks = Math.ceil(fileSize / progress.chunkSize);
|
|
68
|
+
progress.resetUploadedChunks();
|
|
69
|
+
uploadsProgressMap.set(fileId, progress);
|
|
70
|
+
progresses.push(progress);
|
|
71
|
+
}
|
|
72
|
+
progress.savedLocation = finalPath;
|
|
73
|
+
|
|
74
|
+
progress.resetVerificationCount();
|
|
75
|
+
progress.uploadingChunks = new Set<number>();
|
|
76
|
+
res.json({
|
|
77
|
+
fileId,
|
|
78
|
+
chunkSize: progress.chunkSize,
|
|
79
|
+
totalChunks: progress.totalChunks,
|
|
80
|
+
maxParallel: MAX_PARALLEL_CHUNK_UPLOADS,
|
|
81
|
+
bytesReceived: progress.bytesReceived || 0 // client can skip uploaded chunks
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {Request, Response, Router} from 'express';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import {throttledBroadcaster, uploadsProgressMap} from '../globals';
|
|
5
|
+
import {FileTransferProgress, UploadStatus} from "../model/progress";
|
|
6
|
+
|
|
7
|
+
export const router = Router();
|
|
8
|
+
|
|
9
|
+
/* ------------------------ Helper: uniform error JSON ------------------------ */
|
|
10
|
+
const sendError = (res: Response, code: number, msg: string): Response =>
|
|
11
|
+
res.status(code).json({ msg });
|
|
12
|
+
|
|
13
|
+
const hashMatched = (fileId: string,
|
|
14
|
+
chunkIndex: number,
|
|
15
|
+
res: Response,
|
|
16
|
+
progress: FileTransferProgress): Response => {
|
|
17
|
+
console.log(`✅ Hash match for chunk ${chunkIndex} of file ${fileId}`);
|
|
18
|
+
progress.addUploadedChunk(chunkIndex);
|
|
19
|
+
progress.chunkVerified(chunkIndex);
|
|
20
|
+
throttledBroadcaster();
|
|
21
|
+
return res.json({
|
|
22
|
+
fileId,
|
|
23
|
+
chunkIndex,
|
|
24
|
+
hashMatches: true,
|
|
25
|
+
bytesReceived: progress.bytesReceived,
|
|
26
|
+
bytesExpected: progress.bytesExpected
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const hashMismatched = (fileId: string,
|
|
31
|
+
chunkIndex: number,
|
|
32
|
+
res: Response,
|
|
33
|
+
progress: FileTransferProgress): Response => {
|
|
34
|
+
console.log(`❌ Hash mismatch for chunk ${chunkIndex} of file ${fileId}`);
|
|
35
|
+
progress.uploadingChunks.add(chunkIndex);
|
|
36
|
+
progress.chunkVerified(chunkIndex);
|
|
37
|
+
throttledBroadcaster();
|
|
38
|
+
return res.json({
|
|
39
|
+
fileId,
|
|
40
|
+
chunkIndex,
|
|
41
|
+
hashMatches: false,
|
|
42
|
+
bytesReceived: progress.bytesReceived,
|
|
43
|
+
bytesExpected: progress.bytesExpected
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* ------------------------ Helper: read chunk from disk ---------------------- */
|
|
48
|
+
const readChunk = (filePath: string,
|
|
49
|
+
start: number,
|
|
50
|
+
length: number): Buffer<ArrayBuffer> | null => {
|
|
51
|
+
const fd = fs.openSync(filePath, 'r');
|
|
52
|
+
|
|
53
|
+
const stats = fs.fstatSync(fd);
|
|
54
|
+
const fileSize = stats.size;
|
|
55
|
+
|
|
56
|
+
// Chunk beyond EOF → return null (means "not written")
|
|
57
|
+
if (start >= fileSize) {
|
|
58
|
+
fs.closeSync(fd);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const realEnd = Math.min(start + length, fileSize);
|
|
63
|
+
const realLength = realEnd - start;
|
|
64
|
+
|
|
65
|
+
const buffer = Buffer.allocUnsafe(realLength);
|
|
66
|
+
fs.readSync(fd, buffer, 0, realLength, start);
|
|
67
|
+
fs.closeSync(fd);
|
|
68
|
+
|
|
69
|
+
return buffer;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// GET /upload/status?fileId=UUID&chunkIndex=N&chunkSize=BYTES&hash=SHA1
|
|
73
|
+
router.get('/', (req: Request, res: Response) => {
|
|
74
|
+
// Cast query params to string explicitly
|
|
75
|
+
const fileId = (req.query.fileId as string | undefined);
|
|
76
|
+
const chunkIdxStr = req.query.chunkIndex as string | undefined;
|
|
77
|
+
const chunkSizeStr = req.query.chunkSize as string | undefined;
|
|
78
|
+
const clientHash = req.query.hash as string | undefined;
|
|
79
|
+
|
|
80
|
+
if (!fileId) {
|
|
81
|
+
return res.status(400).json({ msg: `Missing query parameter fileId`});
|
|
82
|
+
}
|
|
83
|
+
if (!chunkIdxStr) {
|
|
84
|
+
return res.status(400).json({ msg: `Missing query parameter chunkIndex`});
|
|
85
|
+
}
|
|
86
|
+
if (!chunkSizeStr) {
|
|
87
|
+
return res.status(400).json({ msg: `Missing query parameter chunkSize`});
|
|
88
|
+
}
|
|
89
|
+
if (!clientHash) {
|
|
90
|
+
return res.status(400).json({ msg: `Missing query parameter hash`});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const chunkIndex = parseInt(chunkIdxStr, 10);
|
|
94
|
+
const chunkSize = parseInt(chunkSizeStr, 10);
|
|
95
|
+
|
|
96
|
+
if (!uploadsProgressMap.has(fileId)) {
|
|
97
|
+
return res.status(400).json({ msg: 'Invalid or unknown fileId' });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const progress = uploadsProgressMap.get(fileId)! as FileTransferProgress;
|
|
101
|
+
progress.lastState = UploadStatus.UPLOADING;
|
|
102
|
+
throttledBroadcaster();
|
|
103
|
+
|
|
104
|
+
if (!chunkSize || chunkSize <= 0 || !progress.bytesExpected) {
|
|
105
|
+
return sendError(res, 400, "Invalid chunk size");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const totalChunks = Math.ceil(progress.bytesExpected / chunkSize);
|
|
109
|
+
if (isNaN(chunkIndex) || chunkIndex < 0 || chunkIndex >= totalChunks) {
|
|
110
|
+
return sendError(res, 400, "Invalid chunk index");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const filePath = progress.savedLocation;
|
|
114
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
115
|
+
return sendError(res, 404, "File not found");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---- Compute read boundaries ----
|
|
119
|
+
const start = chunkIndex * chunkSize;
|
|
120
|
+
const length = Math.min(chunkSize, progress.bytesExpected - start);
|
|
121
|
+
|
|
122
|
+
// ---- Read chunk ----
|
|
123
|
+
const buffer = readChunk(filePath, start, length);
|
|
124
|
+
if (!buffer) {
|
|
125
|
+
// Chunk not written yet (beyond EOF)
|
|
126
|
+
return hashMismatched(fileId, chunkIndex, res, progress);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- Hash verification ----
|
|
130
|
+
const serverHash = crypto.createHash('sha1').update(buffer).digest('hex');
|
|
131
|
+
const hashMatches = serverHash === clientHash;
|
|
132
|
+
if (hashMatches) {
|
|
133
|
+
return hashMatched(fileId, chunkIndex, res, progress);
|
|
134
|
+
|
|
135
|
+
}
|
|
136
|
+
return hashMismatched(fileId, chunkIndex, res, progress);
|
|
137
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {FileTransferProgress, Progress, TransferSample} from "../model/progress";
|
|
2
|
+
import { Server } from "socket.io";
|
|
3
|
+
|
|
4
|
+
export class ProgressWriter {
|
|
5
|
+
io: Server;
|
|
6
|
+
|
|
7
|
+
constructor(io: Server) {
|
|
8
|
+
this.io = io;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public writeProgress(progresses: Progress[]) {
|
|
12
|
+
// Clone progresses to safely emit without sending full transferSamples
|
|
13
|
+
const progressesEmittable: FileTransferProgress[] = progresses.map(p => p as FileTransferProgress).map(p => {
|
|
14
|
+
const cloned = this.cloneObjectExceptField(p, "transferSamples") as any;
|
|
15
|
+
|
|
16
|
+
// Only include first and last sample for minimal data
|
|
17
|
+
cloned.transferSamples = [] as TransferSample[];
|
|
18
|
+
if (p.transferSamples && p.transferSamples.length >= 2) {
|
|
19
|
+
cloned.transferSamples.push(p.transferSamples[0]);
|
|
20
|
+
cloned.transferSamples.push(p.transferSamples[p.transferSamples.length - 1]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Ensure uploadedChunks, verifyingChunks, and uploadingChunks are serialized as arrays
|
|
24
|
+
cloned.uploadedChunks = p.uploadedChunks instanceof Set ? Array.from(p.uploadedChunks)
|
|
25
|
+
: Array.isArray(p.uploadedChunks) ? p.uploadedChunks : [];
|
|
26
|
+
|
|
27
|
+
cloned.uploadingChunks = p.uploadingChunks instanceof Set ? Array.from(p.uploadingChunks)
|
|
28
|
+
: Array.isArray(p.uploadingChunks) ? p.uploadingChunks : [];
|
|
29
|
+
|
|
30
|
+
return cloned as FileTransferProgress;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Emit to all connected clients
|
|
34
|
+
this.io.emit("progresses", progressesEmittable);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private cloneObjectExceptField<T extends Record<string, any>, K extends keyof T>(
|
|
38
|
+
obj: T,
|
|
39
|
+
fieldToExclude: Extract<keyof T, string>
|
|
40
|
+
): Omit<T, K> {
|
|
41
|
+
const clonedObject = {} as Omit<T, K>;
|
|
42
|
+
|
|
43
|
+
for (const key in obj) {
|
|
44
|
+
if (obj.hasOwnProperty(key) && key !== fieldToExclude) {
|
|
45
|
+
const c: Record<string, any> = clonedObject;
|
|
46
|
+
c[key.toString()] = obj[key];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return clonedObject;
|
|
51
|
+
}
|
|
52
|
+
}
|