@valentinkolb/filegate 2.2.0 → 2.3.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/LICENSE +1 -1
- package/README.md +67 -0
- package/package.json +2 -1
- package/src/client.ts +57 -0
- package/src/handlers/thumbnail.ts +117 -0
- package/src/handlers/upload.ts +7 -0
- package/src/index.ts +3 -1
- package/src/schemas.ts +57 -0
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -190,6 +190,61 @@ await client.transfer({
|
|
|
190
190
|
- `ensureUniqueName: true` (default) - Appends `-01`, `-02`, etc. if target exists
|
|
191
191
|
- `ensureUniqueName: false` - Overwrites existing target file
|
|
192
192
|
|
|
193
|
+
### Thumbnails
|
|
194
|
+
|
|
195
|
+
Filegate can generate image thumbnails on-the-fly using Sharp. No caching - thumbnails are generated per request (typically 5-20ms).
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
// Get a 200x200 cover thumbnail (default)
|
|
199
|
+
const result = await client.thumbnail.image({
|
|
200
|
+
path: "/data/photos/vacation.jpg",
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (result.ok) {
|
|
204
|
+
const blob = await result.data.blob();
|
|
205
|
+
// Use as image source
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Customized thumbnail
|
|
209
|
+
const result = await client.thumbnail.image({
|
|
210
|
+
path: "/data/photos/vacation.jpg",
|
|
211
|
+
width: 400,
|
|
212
|
+
height: 300,
|
|
213
|
+
fit: "contain", // Fit within bounds, preserve aspect ratio
|
|
214
|
+
format: "webp", // Output format
|
|
215
|
+
quality: 90, // Higher quality
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Smart cropping with attention detection
|
|
219
|
+
const result = await client.thumbnail.image({
|
|
220
|
+
path: "/data/photos/portrait.jpg",
|
|
221
|
+
width: 150,
|
|
222
|
+
height: 150,
|
|
223
|
+
fit: "cover",
|
|
224
|
+
position: "attention", // Focus on interesting areas
|
|
225
|
+
});
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**Options:**
|
|
229
|
+
|
|
230
|
+
| Parameter | Default | Description |
|
|
231
|
+
|-----------|---------|-------------|
|
|
232
|
+
| `width` | 200 | Width in pixels (max 2000) |
|
|
233
|
+
| `height` | 200 | Height in pixels (max 2000) |
|
|
234
|
+
| `fit` | `cover` | `cover`, `contain`, `fill`, `inside`, `outside` |
|
|
235
|
+
| `position` | `center` | Crop position: `center`, `top`, `bottom`, `left`, `right`, `entropy`, `attention` |
|
|
236
|
+
| `format` | `webp` | Output: `webp`, `jpeg`, `png`, `avif` |
|
|
237
|
+
| `quality` | 80 | Quality 1-100 |
|
|
238
|
+
|
|
239
|
+
**Fit modes:**
|
|
240
|
+
- `cover` - Fill the box, crop excess (best for uniform grids)
|
|
241
|
+
- `contain` - Fit within box, preserve aspect ratio (may have letterboxing)
|
|
242
|
+
- `fill` - Stretch to exact size (distorts)
|
|
243
|
+
- `inside` - Like contain, but never upscale
|
|
244
|
+
- `outside` - Like cover, but never downscale
|
|
245
|
+
|
|
246
|
+
**Supported input formats:** JPEG, PNG, WebP, AVIF, TIFF, GIF, SVG
|
|
247
|
+
|
|
193
248
|
### Chunked Uploads
|
|
194
249
|
|
|
195
250
|
For large files, use chunked uploads. They support:
|
|
@@ -383,6 +438,17 @@ await client.glob({
|
|
|
383
438
|
pattern: "**/*",
|
|
384
439
|
directories: true,
|
|
385
440
|
});
|
|
441
|
+
|
|
442
|
+
// Generate image thumbnail
|
|
443
|
+
await client.thumbnail.image({
|
|
444
|
+
path: "/data/photo.jpg",
|
|
445
|
+
width: 200,
|
|
446
|
+
height: 200,
|
|
447
|
+
fit: "cover",
|
|
448
|
+
position: "center",
|
|
449
|
+
format: "webp",
|
|
450
|
+
quality: 80,
|
|
451
|
+
});
|
|
386
452
|
```
|
|
387
453
|
|
|
388
454
|
### Response Format
|
|
@@ -462,6 +528,7 @@ All `/files/*` endpoints require `Authorization: Bearer <token>`.
|
|
|
462
528
|
| GET | `/files/search` | Search with glob pattern. Use `?directories=true` to include folders |
|
|
463
529
|
| POST | `/files/upload/start` | Start or resume chunked upload |
|
|
464
530
|
| POST | `/files/upload/chunk` | Upload a chunk |
|
|
531
|
+
| GET | `/files/thumbnail/image` | Generate image thumbnail on-the-fly |
|
|
465
532
|
|
|
466
533
|
## Security
|
|
467
534
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@valentinkolb/filegate",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"hono": "^4.11.7",
|
|
47
47
|
"hono-openapi": "^1.2.0",
|
|
48
48
|
"sanitize-filename": "^1.6.3",
|
|
49
|
+
"sharp": "^0.34.5",
|
|
49
50
|
"zod": "^4.3.6"
|
|
50
51
|
}
|
|
51
52
|
}
|
package/src/client.ts
CHANGED
|
@@ -120,6 +120,60 @@ export interface GlobOptions {
|
|
|
120
120
|
directories?: boolean;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
// --- Thumbnail ---
|
|
124
|
+
export type ThumbnailFit = "cover" | "contain" | "fill" | "inside" | "outside";
|
|
125
|
+
export type ThumbnailPosition = "center" | "top" | "bottom" | "left" | "right" | "entropy" | "attention";
|
|
126
|
+
export type ThumbnailFormat = "webp" | "jpeg" | "png" | "avif";
|
|
127
|
+
|
|
128
|
+
export interface ImageThumbnailOptions {
|
|
129
|
+
path: string;
|
|
130
|
+
/** Width in pixels (default: 200, max: 2000) */
|
|
131
|
+
width?: number;
|
|
132
|
+
/** Height in pixels (default: 200, max: 2000) */
|
|
133
|
+
height?: number;
|
|
134
|
+
/** Scaling mode (default: cover) */
|
|
135
|
+
fit?: ThumbnailFit;
|
|
136
|
+
/** Crop position for cover fit (default: center) */
|
|
137
|
+
position?: ThumbnailPosition;
|
|
138
|
+
/** Output format (default: webp) */
|
|
139
|
+
format?: ThumbnailFormat;
|
|
140
|
+
/** Quality 1-100 (default: 80) */
|
|
141
|
+
quality?: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ============================================================================
|
|
145
|
+
// Thumbnail Namespace Class
|
|
146
|
+
// ============================================================================
|
|
147
|
+
|
|
148
|
+
class ThumbnailClient {
|
|
149
|
+
constructor(
|
|
150
|
+
private readonly url: string,
|
|
151
|
+
private readonly hdrs: () => Headers,
|
|
152
|
+
private readonly _fetch: typeof fetch,
|
|
153
|
+
) {}
|
|
154
|
+
|
|
155
|
+
async image(opts: ImageThumbnailOptions): Promise<FileProxyResponse<Response>> {
|
|
156
|
+
const params = new URLSearchParams({ path: opts.path });
|
|
157
|
+
if (opts.width !== undefined) params.set("width", String(opts.width));
|
|
158
|
+
if (opts.height !== undefined) params.set("height", String(opts.height));
|
|
159
|
+
if (opts.fit) params.set("fit", opts.fit);
|
|
160
|
+
if (opts.position) params.set("position", opts.position);
|
|
161
|
+
if (opts.format) params.set("format", opts.format);
|
|
162
|
+
if (opts.quality !== undefined) params.set("quality", String(opts.quality));
|
|
163
|
+
|
|
164
|
+
const res = await this._fetch(`${this.url}/files/thumbnail/image?${params}`, {
|
|
165
|
+
headers: this.hdrs(),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (!res.ok) {
|
|
169
|
+
const body = (await res.json().catch(() => ({ error: "unknown error" }))) as ApiError;
|
|
170
|
+
return { ok: false, error: body.error || "unknown error", status: res.status };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { ok: true, data: res };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
123
177
|
// ============================================================================
|
|
124
178
|
// Upload Namespace Class
|
|
125
179
|
// ============================================================================
|
|
@@ -204,6 +258,7 @@ export class Filegate {
|
|
|
204
258
|
private readonly _fetch: typeof fetch;
|
|
205
259
|
|
|
206
260
|
readonly upload: UploadClient;
|
|
261
|
+
readonly thumbnail: ThumbnailClient;
|
|
207
262
|
|
|
208
263
|
constructor(opts: ClientOptions) {
|
|
209
264
|
this.url = opts.url.replace(/\/$/, "");
|
|
@@ -217,6 +272,8 @@ export class Filegate {
|
|
|
217
272
|
this._fetch,
|
|
218
273
|
(res) => this.handleResponse(res),
|
|
219
274
|
);
|
|
275
|
+
|
|
276
|
+
this.thumbnail = new ThumbnailClient(this.url, () => this.hdrs(), this._fetch);
|
|
220
277
|
}
|
|
221
278
|
|
|
222
279
|
private hdrs(): Headers {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { describeRoute } from "hono-openapi";
|
|
3
|
+
import sharp from "sharp";
|
|
4
|
+
import { validatePath } from "../lib/path";
|
|
5
|
+
import { jsonResponse, requiresAuth } from "../lib/openapi";
|
|
6
|
+
import { v } from "../lib/validator";
|
|
7
|
+
import { ImageThumbnailQuerySchema, ErrorSchema } from "../schemas";
|
|
8
|
+
|
|
9
|
+
const app = new Hono();
|
|
10
|
+
|
|
11
|
+
// Supported image MIME types
|
|
12
|
+
const SUPPORTED_IMAGE_TYPES = new Set([
|
|
13
|
+
"image/jpeg",
|
|
14
|
+
"image/png",
|
|
15
|
+
"image/webp",
|
|
16
|
+
"image/avif",
|
|
17
|
+
"image/tiff",
|
|
18
|
+
"image/gif",
|
|
19
|
+
"image/svg+xml",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
// Format to MIME type mapping
|
|
23
|
+
const FORMAT_MIME: Record<string, string> = {
|
|
24
|
+
webp: "image/webp",
|
|
25
|
+
jpeg: "image/jpeg",
|
|
26
|
+
png: "image/png",
|
|
27
|
+
avif: "image/avif",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// GET /thumbnail/image - Generate image thumbnail
|
|
31
|
+
app.get(
|
|
32
|
+
"/image",
|
|
33
|
+
describeRoute({
|
|
34
|
+
tags: ["Thumbnail"],
|
|
35
|
+
summary: "Generate image thumbnail",
|
|
36
|
+
description:
|
|
37
|
+
"Generate a thumbnail from an image file on-the-fly using Sharp. " +
|
|
38
|
+
"Supports JPEG, PNG, WebP, AVIF, TIFF, GIF, and SVG input formats.",
|
|
39
|
+
...requiresAuth,
|
|
40
|
+
responses: {
|
|
41
|
+
200: {
|
|
42
|
+
description: "Thumbnail image",
|
|
43
|
+
content: {
|
|
44
|
+
"image/webp": { schema: { type: "string", format: "binary" } },
|
|
45
|
+
"image/jpeg": { schema: { type: "string", format: "binary" } },
|
|
46
|
+
"image/png": { schema: { type: "string", format: "binary" } },
|
|
47
|
+
"image/avif": { schema: { type: "string", format: "binary" } },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
400: jsonResponse(ErrorSchema, "Invalid parameters or not an image"),
|
|
51
|
+
403: jsonResponse(ErrorSchema, "Forbidden"),
|
|
52
|
+
404: jsonResponse(ErrorSchema, "Not found"),
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
v("query", ImageThumbnailQuerySchema),
|
|
56
|
+
async (c) => {
|
|
57
|
+
const { path, width, height, fit, position, format, quality } = c.req.valid("query");
|
|
58
|
+
|
|
59
|
+
// Validate path
|
|
60
|
+
const result = await validatePath(path);
|
|
61
|
+
if (!result.ok) return c.json({ error: result.error }, result.status);
|
|
62
|
+
|
|
63
|
+
// Check if file exists and is a file
|
|
64
|
+
const file = Bun.file(result.realPath);
|
|
65
|
+
if (!(await file.exists())) {
|
|
66
|
+
return c.json({ error: "file not found" }, 404);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Check MIME type
|
|
70
|
+
if (!SUPPORTED_IMAGE_TYPES.has(file.type)) {
|
|
71
|
+
return c.json(
|
|
72
|
+
{ error: `unsupported image type: ${file.type}. Supported: ${[...SUPPORTED_IMAGE_TYPES].join(", ")}` },
|
|
73
|
+
400,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
// Read file and process with Sharp
|
|
79
|
+
const buffer = await file.arrayBuffer();
|
|
80
|
+
|
|
81
|
+
let pipeline = sharp(Buffer.from(buffer)).resize(width, height, {
|
|
82
|
+
fit,
|
|
83
|
+
position,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Apply format and quality
|
|
87
|
+
switch (format) {
|
|
88
|
+
case "webp":
|
|
89
|
+
pipeline = pipeline.webp({ quality });
|
|
90
|
+
break;
|
|
91
|
+
case "jpeg":
|
|
92
|
+
pipeline = pipeline.jpeg({ quality });
|
|
93
|
+
break;
|
|
94
|
+
case "png":
|
|
95
|
+
pipeline = pipeline.png({ quality });
|
|
96
|
+
break;
|
|
97
|
+
case "avif":
|
|
98
|
+
pipeline = pipeline.avif({ quality });
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const thumbnail = await pipeline.toBuffer();
|
|
103
|
+
|
|
104
|
+
return new Response(thumbnail, {
|
|
105
|
+
headers: {
|
|
106
|
+
"Content-Type": FORMAT_MIME[format],
|
|
107
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error("[Filegate] Thumbnail error:", err);
|
|
112
|
+
return c.json({ error: "failed to generate thumbnail" }, 500);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
export default app;
|
package/src/handlers/upload.ts
CHANGED
|
@@ -83,6 +83,13 @@ const assembleFile = async (meta: UploadMeta): Promise<string | null> => {
|
|
|
83
83
|
const chunks = await getUploadedChunks(meta.uploadId);
|
|
84
84
|
if (chunks.length !== meta.totalChunks) return "missing chunks";
|
|
85
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
|
+
}
|
|
92
|
+
|
|
86
93
|
const fullPath = join(meta.path, meta.filename);
|
|
87
94
|
const pathResult = await validatePath(fullPath);
|
|
88
95
|
if (!pathResult.ok) return pathResult.error;
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { openApiMeta } from "./lib/openapi";
|
|
|
11
11
|
import filesRoutes from "./handlers/files";
|
|
12
12
|
import searchRoutes from "./handlers/search";
|
|
13
13
|
import uploadRoutes, { cleanupOrphanedChunks } from "./handlers/upload";
|
|
14
|
+
import thumbnailRoutes from "./handlers/thumbnail";
|
|
14
15
|
|
|
15
16
|
// Dev mode warning
|
|
16
17
|
if (config.isDev) {
|
|
@@ -56,7 +57,8 @@ const api = new Hono()
|
|
|
56
57
|
.use("/*", bearerAuth({ token: config.token }))
|
|
57
58
|
.route("/", filesRoutes)
|
|
58
59
|
.route("/", searchRoutes)
|
|
59
|
-
.route("/upload", uploadRoutes)
|
|
60
|
+
.route("/upload", uploadRoutes)
|
|
61
|
+
.route("/thumbnail", thumbnailRoutes);
|
|
60
62
|
|
|
61
63
|
app.route("/files", api);
|
|
62
64
|
|
package/src/schemas.ts
CHANGED
|
@@ -260,6 +260,63 @@ export const UploadChunkHeadersSchema = z
|
|
|
260
260
|
})
|
|
261
261
|
.describe("Headers for uploading a chunk");
|
|
262
262
|
|
|
263
|
+
// ============================================================================
|
|
264
|
+
// Thumbnail Schemas
|
|
265
|
+
// ============================================================================
|
|
266
|
+
|
|
267
|
+
export const ThumbnailFitSchema = z
|
|
268
|
+
.enum(["cover", "contain", "fill", "inside", "outside"])
|
|
269
|
+
.describe(
|
|
270
|
+
"Scaling mode: cover (crop to fill), contain (fit within), fill (stretch), inside (fit, never upscale), outside (cover, never downscale)",
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
export const ThumbnailPositionSchema = z
|
|
274
|
+
.enum(["center", "top", "bottom", "left", "right", "entropy", "attention"])
|
|
275
|
+
.describe("Crop position for 'cover' fit: cardinal directions, or 'entropy'/'attention' for smart cropping");
|
|
276
|
+
|
|
277
|
+
export const ThumbnailFormatSchema = z.enum(["webp", "jpeg", "png", "avif"]).describe("Output image format");
|
|
278
|
+
|
|
279
|
+
export const ImageThumbnailQuerySchema = z
|
|
280
|
+
.object({
|
|
281
|
+
path: z.string().min(1).describe("Absolute path to the image file"),
|
|
282
|
+
width: z
|
|
283
|
+
.string()
|
|
284
|
+
.optional()
|
|
285
|
+
.transform((v) => (v ? parseInt(v, 10) : 200))
|
|
286
|
+
.refine((v) => v >= 1 && v <= 2000, "width must be between 1 and 2000")
|
|
287
|
+
.describe("Thumbnail width in pixels (default: 200, max: 2000)"),
|
|
288
|
+
height: z
|
|
289
|
+
.string()
|
|
290
|
+
.optional()
|
|
291
|
+
.transform((v) => (v ? parseInt(v, 10) : 200))
|
|
292
|
+
.refine((v) => v >= 1 && v <= 2000, "height must be between 1 and 2000")
|
|
293
|
+
.describe("Thumbnail height in pixels (default: 200, max: 2000)"),
|
|
294
|
+
fit: z
|
|
295
|
+
.string()
|
|
296
|
+
.optional()
|
|
297
|
+
.transform((v) => (v as z.infer<typeof ThumbnailFitSchema>) || "cover")
|
|
298
|
+
.describe("Scaling mode (default: cover)"),
|
|
299
|
+
position: z
|
|
300
|
+
.string()
|
|
301
|
+
.optional()
|
|
302
|
+
.transform((v) => (v as z.infer<typeof ThumbnailPositionSchema>) || "center")
|
|
303
|
+
.describe("Crop position for cover fit (default: center)"),
|
|
304
|
+
format: z
|
|
305
|
+
.string()
|
|
306
|
+
.optional()
|
|
307
|
+
.transform((v) => (v as z.infer<typeof ThumbnailFormatSchema>) || "webp")
|
|
308
|
+
.describe("Output format (default: webp)"),
|
|
309
|
+
quality: z
|
|
310
|
+
.string()
|
|
311
|
+
.optional()
|
|
312
|
+
.transform((v) => (v ? parseInt(v, 10) : 80))
|
|
313
|
+
.refine((v) => v >= 1 && v <= 100, "quality must be between 1 and 100")
|
|
314
|
+
.describe("Output quality 1-100 (default: 80)"),
|
|
315
|
+
})
|
|
316
|
+
.describe("Query parameters for image thumbnail generation");
|
|
317
|
+
|
|
318
|
+
export type ImageThumbnailQuery = z.infer<typeof ImageThumbnailQuerySchema>;
|
|
319
|
+
|
|
263
320
|
// ============================================================================
|
|
264
321
|
// Types
|
|
265
322
|
// ============================================================================
|