@valentinkolb/filegate 2.3.1 → 2.3.3
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 +9 -0
- package/package.json +2 -1
- package/src/handlers/upload.ts +63 -41
package/README.md
CHANGED
|
@@ -245,6 +245,15 @@ const result = await client.thumbnail.image({
|
|
|
245
245
|
|
|
246
246
|
**Supported input formats:** JPEG, PNG, WebP, AVIF, TIFF, GIF, SVG
|
|
247
247
|
|
|
248
|
+
**Caching:** Thumbnails include `ETag`, `Last-Modified`, and `Cache-Control: immutable` headers. Simply pass through the response:
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
const result = await client.thumbnail.image({ path: "/data/photo.jpg" });
|
|
252
|
+
if (!result.ok) return c.json({ error: result.error }, result.status);
|
|
253
|
+
|
|
254
|
+
return result.data; // Response with all headers
|
|
255
|
+
```
|
|
256
|
+
|
|
248
257
|
### Chunked Uploads
|
|
249
258
|
|
|
250
259
|
For large files, use chunked uploads. They support:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@valentinkolb/filegate",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"typescript": "^5"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
+
"@henrygd/semaphore": "^0.1.0",
|
|
43
44
|
"@hono/standard-validator": "^0.2.2",
|
|
44
45
|
"@scalar/hono-api-reference": "^0.9.37",
|
|
45
46
|
"@scalar/openapi-to-markdown": "^0.3.31",
|
package/src/handlers/upload.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Hono } from "hono";
|
|
|
2
2
|
import { describeRoute } from "hono-openapi";
|
|
3
3
|
import { mkdir, readdir, rm, stat, rename, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import { join, dirname } from "node:path";
|
|
5
|
+
import { getSemaphore } from "@henrygd/semaphore";
|
|
5
6
|
import { validatePath } from "../lib/path";
|
|
6
7
|
import { applyOwnership, type Ownership } from "../lib/ownership";
|
|
7
8
|
import { jsonResponse, requiresAuth } from "../lib/openapi";
|
|
@@ -80,56 +81,77 @@ const cleanupUpload = async (id: string): Promise<void> => {
|
|
|
80
81
|
};
|
|
81
82
|
|
|
82
83
|
const assembleFile = async (meta: UploadMeta): Promise<string | null> => {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// Verify all expected chunk indices are present (0 to totalChunks-1)
|
|
87
|
-
const expectedChunks = Array.from({ length: meta.totalChunks }, (_, i) => i);
|
|
88
|
-
const missingChunks = expectedChunks.filter((i) => !chunks.includes(i));
|
|
89
|
-
if (missingChunks.length > 0) {
|
|
90
|
-
return `missing chunk indices: ${missingChunks.join(", ")}`;
|
|
91
|
-
}
|
|
84
|
+
// Use semaphore to prevent concurrent assembly of the same upload
|
|
85
|
+
const semaphore = getSemaphore(`assemble:${meta.uploadId}`, 1);
|
|
86
|
+
await semaphore.acquire();
|
|
92
87
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
88
|
+
try {
|
|
89
|
+
// Check if assembly was already completed by another request while we waited
|
|
90
|
+
const chunks = await getUploadedChunks(meta.uploadId);
|
|
91
|
+
if (chunks.length === 0) {
|
|
92
|
+
// Chunks were cleaned up - assembly was already completed
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (chunks.length !== meta.totalChunks) return "missing chunks";
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
// Verify all expected chunk indices are present (0 to totalChunks-1)
|
|
98
|
+
const expectedChunks = Array.from({ length: meta.totalChunks }, (_, i) => i);
|
|
99
|
+
const missingChunks = expectedChunks.filter((i) => !chunks.includes(i));
|
|
100
|
+
if (missingChunks.length > 0) {
|
|
101
|
+
return `missing chunk indices: ${missingChunks.join(", ")}`;
|
|
102
|
+
}
|
|
98
103
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
104
|
+
const fullPath = join(meta.path, meta.filename);
|
|
105
|
+
const pathResult = await validatePath(fullPath);
|
|
106
|
+
if (!pathResult.ok) return pathResult.error;
|
|
102
107
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
await mkdir(dirname(pathResult.realPath), { recursive: true });
|
|
109
|
+
|
|
110
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
111
|
+
const file = Bun.file(pathResult.realPath);
|
|
112
|
+
const writer = file.writer();
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
for (let i = 0; i < meta.totalChunks; i++) {
|
|
116
|
+
// Stream each chunk instead of loading entirely into memory
|
|
117
|
+
const chunkFile = Bun.file(chunkPath(meta.uploadId, i));
|
|
118
|
+
|
|
119
|
+
// Verify chunk exists before streaming
|
|
120
|
+
if (!(await chunkFile.exists())) {
|
|
121
|
+
writer.end();
|
|
122
|
+
await rm(pathResult.realPath).catch(() => {});
|
|
123
|
+
return `chunk ${i} not found during assembly`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for await (const data of chunkFile.stream()) {
|
|
127
|
+
hasher.update(data);
|
|
128
|
+
writer.write(data);
|
|
129
|
+
}
|
|
110
130
|
}
|
|
131
|
+
await writer.end();
|
|
132
|
+
} catch (e) {
|
|
133
|
+
writer.end();
|
|
134
|
+
await rm(pathResult.realPath).catch(() => {});
|
|
135
|
+
throw e;
|
|
111
136
|
}
|
|
112
|
-
await writer.end();
|
|
113
|
-
} catch (e) {
|
|
114
|
-
writer.end();
|
|
115
|
-
await rm(pathResult.realPath).catch(() => {});
|
|
116
|
-
throw e;
|
|
117
|
-
}
|
|
118
137
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
138
|
+
const finalChecksum = `sha256:${hasher.digest("hex")}`;
|
|
139
|
+
if (finalChecksum !== meta.checksum) {
|
|
140
|
+
await rm(pathResult.realPath).catch(() => {});
|
|
141
|
+
return `checksum mismatch: expected ${meta.checksum}, got ${finalChecksum}`;
|
|
142
|
+
}
|
|
124
143
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
144
|
+
const ownershipError = await applyOwnership(pathResult.realPath, meta.ownership);
|
|
145
|
+
if (ownershipError) {
|
|
146
|
+
await rm(pathResult.realPath).catch(() => {});
|
|
147
|
+
return ownershipError;
|
|
148
|
+
}
|
|
130
149
|
|
|
131
|
-
|
|
132
|
-
|
|
150
|
+
await cleanupUpload(meta.uploadId);
|
|
151
|
+
return null;
|
|
152
|
+
} finally {
|
|
153
|
+
semaphore.release();
|
|
154
|
+
}
|
|
133
155
|
};
|
|
134
156
|
|
|
135
157
|
// POST /upload/start
|