@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.
Files changed (46) hide show
  1. package/.eslintrc.js +178 -0
  2. package/LICENSE +21 -0
  3. package/README.md +115 -0
  4. package/dist/client/1551f4f60c37af51121f.woff2 +0 -0
  5. package/dist/client/2285773e6b4b172f07d9.woff +0 -0
  6. package/dist/client/23f19bb08961f37aaf69.eot +0 -0
  7. package/dist/client/2f517e09eb2ca6650ff5.svg +3717 -0
  8. package/dist/client/4689f52cc96215721344.svg +801 -0
  9. package/dist/client/491974d108fe4002b2aa.ttf +0 -0
  10. package/dist/client/527940b104eb2ea366c8.ttf +0 -0
  11. package/dist/client/77206a6bb316fa0aded5.eot +0 -0
  12. package/dist/client/7a3337626410ca2f4071.woff2 +0 -0
  13. package/dist/client/7a8b4f130182d19a2d7c.svg +5034 -0
  14. package/dist/client/9bbb245e67a133f6e486.eot +0 -0
  15. package/dist/client/bb58e57c48a3e911f15f.woff +0 -0
  16. package/dist/client/be9ee23c0c6390141475.ttf +0 -0
  17. package/dist/client/d878b0a6a1144760244f.woff2 +0 -0
  18. package/dist/client/eeccf4f66002c6f2ba24.woff +0 -0
  19. package/dist/client/favicon.ico +0 -0
  20. package/dist/client/index.html +1 -0
  21. package/dist/client/main.66a16cbe5e2ce036e9a7.bundle.js +39507 -0
  22. package/dist/client/main.6db272040eaab1c51019.css +14 -0
  23. package/dist/index.js +3 -0
  24. package/dist/index.js.LICENSE.txt +273 -0
  25. package/package-gzip.js +30 -0
  26. package/package.json +107 -0
  27. package/src/globals.ts +23 -0
  28. package/src/index.ts +87 -0
  29. package/src/model/progress.ts +175 -0
  30. package/src/model/progress_utils.ts +17 -0
  31. package/src/routes/uploadChunk.ts +125 -0
  32. package/src/routes/uploadComplete.ts +53 -0
  33. package/src/routes/uploadInit.ts +83 -0
  34. package/src/routes/uploadStatus.ts +137 -0
  35. package/src/service/progress_writer.ts +52 -0
  36. package/src-client/entrypoint.ts +273 -0
  37. package/src-client/progress-handler.ts +233 -0
  38. package/src-client/public/favicon.ico +0 -0
  39. package/src-client/public/index.html +67 -0
  40. package/src-client/sha1.ts +19 -0
  41. package/src-client/style.scss +87 -0
  42. package/tsconfig.json +107 -0
  43. package/webpack-client.common.js +29 -0
  44. package/webpack-client.dev.js +51 -0
  45. package/webpack-client.prod.js +65 -0
  46. 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
+ }