@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Valentin Kolb
3
+ Copyright (c) 2026-Present Valentin Kolb
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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.2.0",
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;
@@ -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
  // ============================================================================