@valentinkolb/filegate 2.3.0 → 2.3.2

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 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.0",
3
+ "version": "2.3.2",
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",
@@ -1,5 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import { describeRoute } from "hono-openapi";
3
+ import { stat } from "node:fs/promises";
3
4
  import sharp from "sharp";
4
5
  import { validatePath } from "../lib/path";
5
6
  import { jsonResponse, requiresAuth } from "../lib/openapi";
@@ -8,6 +9,13 @@ import { ImageThumbnailQuerySchema, ErrorSchema } from "../schemas";
8
9
 
9
10
  const app = new Hono();
10
11
 
12
+ // Generate ETag from path, mtime, and thumbnail parameters
13
+ const generateETag = (path: string, mtime: Date, params: string): string => {
14
+ const hasher = new Bun.CryptoHasher("sha256");
15
+ hasher.update(`${path}:${mtime.getTime()}:${params}`);
16
+ return `"${hasher.digest("hex").slice(0, 16)}"`;
17
+ };
18
+
11
19
  // Supported image MIME types
12
20
  const SUPPORTED_IMAGE_TYPES = new Set([
13
21
  "image/jpeg",
@@ -74,6 +82,27 @@ app.get(
74
82
  );
75
83
  }
76
84
 
85
+ // Get file stats for Last-Modified and ETag
86
+ const fileStat = await stat(result.realPath);
87
+ const lastModified = fileStat.mtime;
88
+ const paramsKey = `${width}x${height}:${fit}:${position}:${format}:${quality}`;
89
+ const etag = generateETag(result.realPath, lastModified, paramsKey);
90
+
91
+ // Check If-None-Match (ETag)
92
+ const ifNoneMatch = c.req.header("If-None-Match");
93
+ if (ifNoneMatch === etag) {
94
+ return new Response(null, { status: 304 });
95
+ }
96
+
97
+ // Check If-Modified-Since
98
+ const ifModifiedSince = c.req.header("If-Modified-Since");
99
+ if (ifModifiedSince) {
100
+ const clientDate = new Date(ifModifiedSince);
101
+ if (lastModified <= clientDate) {
102
+ return new Response(null, { status: 304 });
103
+ }
104
+ }
105
+
77
106
  try {
78
107
  // Read file and process with Sharp
79
108
  const buffer = await file.arrayBuffer();
@@ -105,6 +134,8 @@ app.get(
105
134
  headers: {
106
135
  "Content-Type": FORMAT_MIME[format],
107
136
  "Cache-Control": "public, max-age=31536000, immutable",
137
+ "Last-Modified": lastModified.toUTCString(),
138
+ ETag: etag,
108
139
  },
109
140
  });
110
141
  } catch (err) {
@@ -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,64 @@ const cleanupUpload = async (id: string): Promise<void> => {
80
81
  };
81
82
 
82
83
  const assembleFile = async (meta: UploadMeta): Promise<string | null> => {
83
- const chunks = await getUploadedChunks(meta.uploadId);
84
- if (chunks.length !== meta.totalChunks) return "missing chunks";
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
- const fullPath = join(meta.path, meta.filename);
94
- const pathResult = await validatePath(fullPath);
95
- if (!pathResult.ok) return pathResult.error;
88
+ try {
89
+ const chunks = await getUploadedChunks(meta.uploadId);
90
+ if (chunks.length !== meta.totalChunks) return "missing chunks";
91
+
92
+ // Verify all expected chunk indices are present (0 to totalChunks-1)
93
+ const expectedChunks = Array.from({ length: meta.totalChunks }, (_, i) => i);
94
+ const missingChunks = expectedChunks.filter((i) => !chunks.includes(i));
95
+ if (missingChunks.length > 0) {
96
+ return `missing chunk indices: ${missingChunks.join(", ")}`;
97
+ }
96
98
 
97
- await mkdir(dirname(pathResult.realPath), { recursive: true });
99
+ const fullPath = join(meta.path, meta.filename);
100
+ const pathResult = await validatePath(fullPath);
101
+ if (!pathResult.ok) return pathResult.error;
98
102
 
99
- const hasher = new Bun.CryptoHasher("sha256");
100
- const file = Bun.file(pathResult.realPath);
101
- const writer = file.writer();
103
+ await mkdir(dirname(pathResult.realPath), { recursive: true });
102
104
 
103
- try {
104
- for (let i = 0; i < meta.totalChunks; i++) {
105
- // Stream each chunk instead of loading entirely into memory
106
- const chunkFile = Bun.file(chunkPath(meta.uploadId, i));
107
- for await (const data of chunkFile.stream()) {
108
- hasher.update(data);
109
- writer.write(data);
105
+ const hasher = new Bun.CryptoHasher("sha256");
106
+ const file = Bun.file(pathResult.realPath);
107
+ const writer = file.writer();
108
+
109
+ try {
110
+ for (let i = 0; i < meta.totalChunks; i++) {
111
+ // Stream each chunk instead of loading entirely into memory
112
+ const chunkFile = Bun.file(chunkPath(meta.uploadId, i));
113
+ for await (const data of chunkFile.stream()) {
114
+ hasher.update(data);
115
+ writer.write(data);
116
+ }
110
117
  }
118
+ await writer.end();
119
+ } catch (e) {
120
+ writer.end();
121
+ await rm(pathResult.realPath).catch(() => {});
122
+ throw e;
111
123
  }
112
- await writer.end();
113
- } catch (e) {
114
- writer.end();
115
- await rm(pathResult.realPath).catch(() => {});
116
- throw e;
117
- }
118
124
 
119
- const finalChecksum = `sha256:${hasher.digest("hex")}`;
120
- if (finalChecksum !== meta.checksum) {
121
- await rm(pathResult.realPath).catch(() => {});
122
- return `checksum mismatch: expected ${meta.checksum}, got ${finalChecksum}`;
123
- }
125
+ const finalChecksum = `sha256:${hasher.digest("hex")}`;
126
+ if (finalChecksum !== meta.checksum) {
127
+ await rm(pathResult.realPath).catch(() => {});
128
+ return `checksum mismatch: expected ${meta.checksum}, got ${finalChecksum}`;
129
+ }
124
130
 
125
- const ownershipError = await applyOwnership(pathResult.realPath, meta.ownership);
126
- if (ownershipError) {
127
- await rm(pathResult.realPath).catch(() => {});
128
- return ownershipError;
129
- }
131
+ const ownershipError = await applyOwnership(pathResult.realPath, meta.ownership);
132
+ if (ownershipError) {
133
+ await rm(pathResult.realPath).catch(() => {});
134
+ return ownershipError;
135
+ }
130
136
 
131
- await cleanupUpload(meta.uploadId);
132
- return null;
137
+ await cleanupUpload(meta.uploadId);
138
+ return null;
139
+ } finally {
140
+ semaphore.release();
141
+ }
133
142
  };
134
143
 
135
144
  // POST /upload/start