@rebasepro/server-core 0.2.3 → 0.2.4
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/dist/common/src/collections/default-collections.d.ts +12 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/util/permissions.d.ts +1 -0
- package/dist/{index-BZoAtuqi.js → index-Cr1D21av.js} +11 -3
- package/dist/index-Cr1D21av.js.map +1 -0
- package/dist/index.es.js +2166 -208
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +2155 -193
- package/dist/index.umd.js.map +1 -1
- package/dist/server-core/src/api/logs-routes.d.ts +37 -0
- package/dist/server-core/src/api/rest/api-generator.d.ts +6 -0
- package/dist/server-core/src/api/types.d.ts +6 -1
- package/dist/server-core/src/auth/adapter-middleware.d.ts +7 -3
- package/dist/server-core/src/auth/admin-routes.d.ts +3 -3
- package/dist/server-core/src/auth/api-keys/api-key-middleware.d.ts +39 -0
- package/dist/server-core/src/auth/api-keys/api-key-permission-guard.d.ts +32 -0
- package/dist/server-core/src/auth/api-keys/api-key-routes.d.ts +20 -0
- package/dist/server-core/src/auth/api-keys/api-key-store.d.ts +35 -0
- package/dist/server-core/src/auth/api-keys/api-key-types.d.ts +88 -0
- package/dist/server-core/src/auth/api-keys/index.d.ts +17 -0
- package/dist/server-core/src/auth/{auth-overrides.d.ts → auth-hooks.d.ts} +69 -11
- package/dist/server-core/src/auth/builtin-auth-adapter.d.ts +3 -3
- package/dist/server-core/src/auth/index.d.ts +5 -3
- package/dist/server-core/src/auth/interfaces.d.ts +93 -3
- package/dist/server-core/src/auth/jwt.d.ts +3 -1
- package/dist/server-core/src/auth/mfa.d.ts +49 -0
- package/dist/server-core/src/auth/middleware.d.ts +7 -0
- package/dist/server-core/src/auth/rate-limiter.d.ts +19 -0
- package/dist/server-core/src/auth/routes.d.ts +3 -3
- package/dist/server-core/src/env.d.ts +6 -0
- package/dist/server-core/src/index.d.ts +1 -0
- package/dist/server-core/src/init.d.ts +3 -3
- package/dist/server-core/src/services/webhook-service.d.ts +29 -0
- package/dist/server-core/src/storage/image-transform.d.ts +48 -0
- package/dist/server-core/src/storage/index.d.ts +3 -0
- package/dist/server-core/src/storage/tus-handler.d.ts +51 -0
- package/dist/types/src/controllers/auth.d.ts +2 -24
- package/dist/types/src/controllers/client.d.ts +0 -3
- package/dist/types/src/controllers/collection_registry.d.ts +1 -1
- package/dist/types/src/controllers/data_driver.d.ts +18 -0
- package/dist/types/src/controllers/registry.d.ts +5 -4
- package/dist/types/src/rebase_context.d.ts +1 -1
- package/dist/types/src/types/auth_adapter.d.ts +2 -4
- package/dist/types/src/types/collections.d.ts +0 -4
- package/dist/types/src/types/component_ref.d.ts +1 -1
- package/dist/types/src/types/cron.d.ts +1 -1
- package/dist/types/src/types/entity_views.d.ts +1 -0
- package/dist/types/src/types/export_import.d.ts +1 -1
- package/dist/types/src/types/formex.d.ts +2 -2
- package/dist/types/src/types/properties.d.ts +2 -2
- package/dist/types/src/types/translations.d.ts +28 -12
- package/dist/types/src/types/user_management_delegate.d.ts +6 -4
- package/dist/types/src/users/roles.d.ts +0 -8
- package/package.json +8 -6
- package/src/api/ast-schema-editor.ts +4 -4
- package/src/api/errors.ts +16 -7
- package/src/api/logs-routes.ts +129 -0
- package/src/api/rest/api-generator.ts +42 -2
- package/src/api/rest/query-parser.ts +37 -1
- package/src/api/types.ts +6 -1
- package/src/auth/adapter-middleware.ts +20 -4
- package/src/auth/admin-routes.ts +39 -14
- package/src/auth/api-keys/api-key-middleware.ts +126 -0
- package/src/auth/api-keys/api-key-permission-guard.ts +64 -0
- package/src/auth/api-keys/api-key-routes.ts +183 -0
- package/src/auth/api-keys/api-key-store.ts +317 -0
- package/src/auth/api-keys/api-key-types.ts +94 -0
- package/src/auth/api-keys/index.ts +37 -0
- package/src/auth/{auth-overrides.ts → auth-hooks.ts} +81 -14
- package/src/auth/builtin-auth-adapter.ts +31 -19
- package/src/auth/index.ts +7 -3
- package/src/auth/interfaces.ts +111 -3
- package/src/auth/jwt.ts +19 -5
- package/src/auth/mfa.ts +160 -0
- package/src/auth/middleware.ts +20 -1
- package/src/auth/rate-limiter.ts +92 -0
- package/src/auth/routes.ts +455 -24
- package/src/cron/cron-loader.ts +5 -10
- package/src/cron/cron-scheduler.ts +11 -12
- package/src/cron/cron-store.ts +8 -7
- package/src/env.ts +2 -0
- package/src/functions/function-loader.ts +6 -9
- package/src/index.ts +1 -2
- package/src/init.ts +37 -7
- package/src/serve-spa.ts +5 -4
- package/src/services/webhook-service.ts +155 -0
- package/src/storage/image-transform.ts +202 -0
- package/src/storage/index.ts +3 -0
- package/src/storage/routes.ts +56 -3
- package/src/storage/tus-handler.ts +315 -0
- package/src/utils/dev-port.ts +14 -0
- package/src/utils/logging.ts +9 -7
- package/test/admin-routes.test.ts +74 -7
- package/test/api-generator.test.ts +0 -1
- package/test/api-key-permission-guard.test.ts +132 -0
- package/test/ast-schema-editor.test.ts +26 -0
- package/test/auth-routes.test.ts +1 -2
- package/test/backend-hooks-admin.test.ts +3 -4
- package/test/email-templates.test.ts +169 -0
- package/test/function-loader.test.ts +124 -0
- package/test/jwt.test.ts +4 -2
- package/test/mfa.test.ts +197 -0
- package/test/middleware.test.ts +10 -5
- package/test/webhook-service.test.ts +249 -0
- package/vite.config.ts +3 -2
- package/dist/index-BZoAtuqi.js.map +0 -1
- package/dist/server-core/src/bootstrappers/index.d.ts +0 -0
- package/src/bootstrappers/index.ts +0 -1
- package/src/singleton.test.ts +0 -28
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Transformation Service
|
|
3
|
+
*
|
|
4
|
+
* Provides on-the-fly image resize, crop, format conversion, and quality
|
|
5
|
+
* adjustment using the `sharp` library. Results are cached in an LRU
|
|
6
|
+
* in-memory cache to avoid redundant processing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Lazy-load sharp to avoid crashing when it isn't installed (e.g. in tests)
|
|
10
|
+
let sharpFactory: ((input: Buffer | Uint8Array) => any) | undefined;
|
|
11
|
+
|
|
12
|
+
async function getSharp(): Promise<(input: Buffer | Uint8Array) => any> {
|
|
13
|
+
if (!sharpFactory) {
|
|
14
|
+
try {
|
|
15
|
+
// @ts-ignore - sharp is an optional dependency
|
|
16
|
+
const mod = await import("sharp");
|
|
17
|
+
sharpFactory = mod.default;
|
|
18
|
+
} catch (err) {
|
|
19
|
+
throw new Error("Failed to load optional 'sharp' dependency for image transformation.");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (!sharpFactory) {
|
|
23
|
+
throw new Error("Failed to load optional 'sharp' dependency for image transformation.");
|
|
24
|
+
}
|
|
25
|
+
return sharpFactory;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Options that can be specified via query parameters. */
|
|
29
|
+
export interface ImageTransformOptions {
|
|
30
|
+
width?: number;
|
|
31
|
+
height?: number;
|
|
32
|
+
quality?: number;
|
|
33
|
+
format?: "webp" | "avif" | "jpeg" | "png";
|
|
34
|
+
fit?: "cover" | "contain" | "fill" | "inside" | "outside";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Maximum dimension allowed (prevents abuse). */
|
|
38
|
+
const MAX_DIMENSION = 4096;
|
|
39
|
+
/** Maximum quality value. */
|
|
40
|
+
const MAX_QUALITY = 100;
|
|
41
|
+
/** Minimum quality value. */
|
|
42
|
+
const MIN_QUALITY = 1;
|
|
43
|
+
|
|
44
|
+
const VALID_FORMATS = new Set(["webp", "avif", "jpeg", "png"]);
|
|
45
|
+
const VALID_FITS = new Set(["cover", "contain", "fill", "inside", "outside"]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse transform options from URL query parameters.
|
|
49
|
+
* Returns `null` when no transformation is requested.
|
|
50
|
+
*/
|
|
51
|
+
export function parseTransformOptions(query: Record<string, string>): ImageTransformOptions | null {
|
|
52
|
+
const opts: ImageTransformOptions = {};
|
|
53
|
+
let hasTransform = false;
|
|
54
|
+
|
|
55
|
+
if (query.width) {
|
|
56
|
+
const w = parseInt(query.width, 10);
|
|
57
|
+
if (!Number.isNaN(w) && w > 0) {
|
|
58
|
+
opts.width = Math.min(w, MAX_DIMENSION);
|
|
59
|
+
hasTransform = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (query.height) {
|
|
64
|
+
const h = parseInt(query.height, 10);
|
|
65
|
+
if (!Number.isNaN(h) && h > 0) {
|
|
66
|
+
opts.height = Math.min(h, MAX_DIMENSION);
|
|
67
|
+
hasTransform = true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (query.quality) {
|
|
72
|
+
const q = parseInt(query.quality, 10);
|
|
73
|
+
if (!Number.isNaN(q)) {
|
|
74
|
+
opts.quality = Math.min(Math.max(q, MIN_QUALITY), MAX_QUALITY);
|
|
75
|
+
hasTransform = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (query.format && VALID_FORMATS.has(query.format)) {
|
|
80
|
+
opts.format = query.format as ImageTransformOptions["format"];
|
|
81
|
+
hasTransform = true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (query.fit && VALID_FITS.has(query.fit)) {
|
|
85
|
+
opts.fit = query.fit as ImageTransformOptions["fit"];
|
|
86
|
+
hasTransform = true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return hasTransform ? opts : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** MIME types that can be used as a Content-Type header. */
|
|
93
|
+
const FORMAT_CONTENT_TYPES: Record<string, string> = {
|
|
94
|
+
webp: "image/webp",
|
|
95
|
+
avif: "image/avif",
|
|
96
|
+
jpeg: "image/jpeg",
|
|
97
|
+
png: "image/png",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Check whether a content type is a transformable image. */
|
|
101
|
+
export function isTransformableImage(contentType: string): boolean {
|
|
102
|
+
return (
|
|
103
|
+
contentType.startsWith("image/") &&
|
|
104
|
+
!contentType.includes("svg") &&
|
|
105
|
+
!contentType.includes("gif")
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Apply image transformations and return the result buffer + content type.
|
|
111
|
+
*/
|
|
112
|
+
export async function transformImage(
|
|
113
|
+
buffer: Buffer | Uint8Array,
|
|
114
|
+
options: ImageTransformOptions
|
|
115
|
+
): Promise<{ data: Buffer; contentType: string }> {
|
|
116
|
+
const sharp = await getSharp();
|
|
117
|
+
let pipeline = sharp(buffer);
|
|
118
|
+
|
|
119
|
+
if (options.width || options.height) {
|
|
120
|
+
pipeline = pipeline.resize({
|
|
121
|
+
width: options.width,
|
|
122
|
+
height: options.height,
|
|
123
|
+
fit: options.fit || "cover",
|
|
124
|
+
withoutEnlargement: true,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const format = options.format || "webp";
|
|
129
|
+
const quality = options.quality || 80;
|
|
130
|
+
|
|
131
|
+
switch (format) {
|
|
132
|
+
case "webp":
|
|
133
|
+
pipeline = pipeline.webp({ quality });
|
|
134
|
+
break;
|
|
135
|
+
case "avif":
|
|
136
|
+
pipeline = pipeline.avif({ quality });
|
|
137
|
+
break;
|
|
138
|
+
case "jpeg":
|
|
139
|
+
pipeline = pipeline.jpeg({ quality });
|
|
140
|
+
break;
|
|
141
|
+
case "png":
|
|
142
|
+
pipeline = pipeline.png({ quality });
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const data = await pipeline.toBuffer();
|
|
147
|
+
return { data, contentType: FORMAT_CONTENT_TYPES[format] };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// LRU Transform Cache
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
interface CacheEntry {
|
|
155
|
+
data: Buffer;
|
|
156
|
+
contentType: string;
|
|
157
|
+
timestamp: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Simple LRU cache for transformed images.
|
|
162
|
+
*
|
|
163
|
+
* Entries expire after `maxAgeMs` (default: 1 hour) and the cache
|
|
164
|
+
* evicts the oldest entry when `maxEntries` is exceeded.
|
|
165
|
+
*/
|
|
166
|
+
export class TransformCache {
|
|
167
|
+
private cache = new Map<string, CacheEntry>();
|
|
168
|
+
private readonly maxEntries: number;
|
|
169
|
+
private readonly maxAgeMs: number;
|
|
170
|
+
|
|
171
|
+
constructor(maxEntries = 500, maxAgeMs = 3_600_000) {
|
|
172
|
+
this.maxEntries = maxEntries;
|
|
173
|
+
this.maxAgeMs = maxAgeMs;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Build a deterministic cache key from file key + transform options. */
|
|
177
|
+
buildKey(fileKey: string, options: ImageTransformOptions): string {
|
|
178
|
+
return `${fileKey}::${JSON.stringify(options)}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
get(cacheKey: string): { data: Buffer; contentType: string } | null {
|
|
182
|
+
const entry = this.cache.get(cacheKey);
|
|
183
|
+
if (!entry) return null;
|
|
184
|
+
if (Date.now() - entry.timestamp > this.maxAgeMs) {
|
|
185
|
+
this.cache.delete(cacheKey);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
// Move to end (most recently used)
|
|
189
|
+
this.cache.delete(cacheKey);
|
|
190
|
+
this.cache.set(cacheKey, entry);
|
|
191
|
+
return { data: entry.data, contentType: entry.contentType };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
set(cacheKey: string, data: Buffer, contentType: string): void {
|
|
195
|
+
// Evict oldest if at capacity
|
|
196
|
+
if (this.cache.size >= this.maxEntries) {
|
|
197
|
+
const oldest = this.cache.keys().next().value;
|
|
198
|
+
if (oldest !== undefined) this.cache.delete(oldest);
|
|
199
|
+
}
|
|
200
|
+
this.cache.set(cacheKey, { data, contentType, timestamp: Date.now() });
|
|
201
|
+
}
|
|
202
|
+
}
|
package/src/storage/index.ts
CHANGED
|
@@ -16,6 +16,9 @@ export { S3StorageController } from "./S3StorageController";
|
|
|
16
16
|
export { createStorageRoutes } from "./routes";
|
|
17
17
|
export type { StorageRoutesConfig } from "./routes";
|
|
18
18
|
export * from "./storage-registry";
|
|
19
|
+
export { parseTransformOptions, transformImage, isTransformableImage, TransformCache } from "./image-transform";
|
|
20
|
+
export type { ImageTransformOptions } from "./image-transform";
|
|
21
|
+
export { TusHandler } from "./tus-handler";
|
|
19
22
|
|
|
20
23
|
import { BackendStorageConfig, StorageController } from "./types";
|
|
21
24
|
import { LocalStorageController } from "./LocalStorageController";
|
package/src/storage/routes.ts
CHANGED
|
@@ -9,6 +9,11 @@ import { LocalStorageController } from "./LocalStorageController";
|
|
|
9
9
|
import { requireAuth as jwtRequireAuth, optionalAuth } from "../auth/middleware";
|
|
10
10
|
import { ApiError, errorHandler } from "../api/errors";
|
|
11
11
|
import { HonoEnv } from "../api/types";
|
|
12
|
+
import { parseTransformOptions, transformImage, isTransformableImage, TransformCache } from "./image-transform";
|
|
13
|
+
import { TusHandler } from "./tus-handler";
|
|
14
|
+
|
|
15
|
+
/** Shared image transform cache (LRU, 500 entries, 1 hour TTL). */
|
|
16
|
+
const transformCache = new TransformCache();
|
|
12
17
|
|
|
13
18
|
export interface StorageRoutesConfig {
|
|
14
19
|
controller: StorageController;
|
|
@@ -129,6 +134,9 @@ export function createStorageRoutes(config: StorageRoutesConfig): Hono<HonoEnv>
|
|
|
129
134
|
|
|
130
135
|
const filePath = decodeURIComponent(rawPath);
|
|
131
136
|
|
|
137
|
+
// Parse image transform query params (e.g. ?width=300&format=webp)
|
|
138
|
+
const transformOpts = parseTransformOptions(c.req.query() as Record<string, string>);
|
|
139
|
+
|
|
132
140
|
// For local storage, serve the file directly from disk
|
|
133
141
|
if (controller.getType() === "local") {
|
|
134
142
|
const localController = controller as LocalStorageController;
|
|
@@ -153,9 +161,22 @@ export function createStorageRoutes(config: StorageRoutesConfig): Hono<HonoEnv>
|
|
|
153
161
|
}
|
|
154
162
|
}
|
|
155
163
|
|
|
156
|
-
c.header("Content-Type", contentType);
|
|
157
|
-
// In a better scenario, we should pipe the stream instead of reading whole file
|
|
158
164
|
const fileContent = fs.readFileSync(absolutePath);
|
|
165
|
+
|
|
166
|
+
// Apply image transforms if requested and the file is a transformable image
|
|
167
|
+
if (transformOpts && isTransformableImage(contentType)) {
|
|
168
|
+
const cacheKey = transformCache.buildKey(filePath, transformOpts);
|
|
169
|
+
let cached = transformCache.get(cacheKey);
|
|
170
|
+
if (!cached) {
|
|
171
|
+
cached = await transformImage(Buffer.from(fileContent), transformOpts);
|
|
172
|
+
transformCache.set(cacheKey, cached.data, cached.contentType);
|
|
173
|
+
}
|
|
174
|
+
c.header("Content-Type", cached.contentType);
|
|
175
|
+
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
176
|
+
return c.body(new Uint8Array(cached.data));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
c.header("Content-Type", contentType);
|
|
159
180
|
return c.body(new Uint8Array(fileContent));
|
|
160
181
|
}
|
|
161
182
|
|
|
@@ -169,7 +190,23 @@ export function createStorageRoutes(config: StorageRoutesConfig): Hono<HonoEnv>
|
|
|
169
190
|
throw ApiError.notFound("File not found");
|
|
170
191
|
}
|
|
171
192
|
|
|
172
|
-
|
|
193
|
+
const remoteContentType = fileObject.type || "application/octet-stream";
|
|
194
|
+
|
|
195
|
+
// Apply image transforms for remote storage too
|
|
196
|
+
if (transformOpts && isTransformableImage(remoteContentType)) {
|
|
197
|
+
const cacheKey = transformCache.buildKey(filePath, transformOpts);
|
|
198
|
+
let cached = transformCache.get(cacheKey);
|
|
199
|
+
if (!cached) {
|
|
200
|
+
const buf = Buffer.from(await fileObject.arrayBuffer());
|
|
201
|
+
cached = await transformImage(buf, transformOpts);
|
|
202
|
+
transformCache.set(cacheKey, cached.data, cached.contentType);
|
|
203
|
+
}
|
|
204
|
+
c.header("Content-Type", cached.contentType);
|
|
205
|
+
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
206
|
+
return c.body(new Uint8Array(cached.data));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
c.header("Content-Type", remoteContentType);
|
|
173
210
|
c.header("Cache-Control", "public, max-age=3600, immutable");
|
|
174
211
|
const buf = await fileObject.arrayBuffer();
|
|
175
212
|
return c.body(new Uint8Array(buf));
|
|
@@ -288,5 +325,21 @@ message: "No file to delete" });
|
|
|
288
325
|
}, 201);
|
|
289
326
|
});
|
|
290
327
|
|
|
328
|
+
// -----------------------------------------------------------------------
|
|
329
|
+
// TUS Resumable Uploads
|
|
330
|
+
// -----------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
const tusBaseDir = controller.getType() === "local"
|
|
333
|
+
? (controller as LocalStorageController).getBasePath()
|
|
334
|
+
: (process.env.STORAGE_PATH || "./uploads");
|
|
335
|
+
const tusHandler = new TusHandler(tusBaseDir, controller);
|
|
336
|
+
tusHandler.startCleanup();
|
|
337
|
+
|
|
338
|
+
router.options("/tus", (_c) => tusHandler.options());
|
|
339
|
+
router.post("/tus", writeAuthMiddleware, async (c) => tusHandler.create(c));
|
|
340
|
+
router.get("/tus/:id", readAuthMiddleware, (c) => tusHandler.head(c, c.req.param("id")));
|
|
341
|
+
router.patch("/tus/:id", writeAuthMiddleware, async (c) => tusHandler.patch(c, c.req.param("id")));
|
|
342
|
+
router.delete("/tus/:id", writeAuthMiddleware, async (c) => tusHandler.delete(c, c.req.param("id")));
|
|
343
|
+
|
|
291
344
|
return router;
|
|
292
345
|
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUS Protocol Handler
|
|
3
|
+
*
|
|
4
|
+
* Implements the TUS v1.0.0 resumable upload protocol with the
|
|
5
|
+
* Creation and Termination extensions. Uploads are stored in a
|
|
6
|
+
* temporary directory and moved to final storage on completion.
|
|
7
|
+
*
|
|
8
|
+
* @see https://tus.io/protocols/resumable-upload
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from "crypto";
|
|
12
|
+
import { writeFile, unlink, stat, mkdir, open } from "fs/promises";
|
|
13
|
+
import { existsSync } from "fs";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
import type { Context } from "hono";
|
|
16
|
+
import type { StorageController } from "./types";
|
|
17
|
+
import { logger } from "../utils/logger.js";
|
|
18
|
+
|
|
19
|
+
/** Metadata for an in-progress resumable upload. */
|
|
20
|
+
interface TusUpload {
|
|
21
|
+
id: string;
|
|
22
|
+
/** Total declared size in bytes. */
|
|
23
|
+
size: number;
|
|
24
|
+
/** Bytes received so far. */
|
|
25
|
+
offset: number;
|
|
26
|
+
/** TUS metadata parsed from the creation request. */
|
|
27
|
+
metadata: Record<string, string>;
|
|
28
|
+
/** Timestamp of creation (epoch ms). */
|
|
29
|
+
createdAt: number;
|
|
30
|
+
/** Absolute path to the temp file on disk. */
|
|
31
|
+
filePath: string;
|
|
32
|
+
/** Target bucket (from metadata). */
|
|
33
|
+
bucket?: string;
|
|
34
|
+
/** Target key / filename (from metadata). */
|
|
35
|
+
key?: string;
|
|
36
|
+
/** Whether the upload has been fully received and finalized. */
|
|
37
|
+
completed: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Maximum upload size: 5 GB. */
|
|
41
|
+
const MAX_UPLOAD_SIZE = 5 * 1024 * 1024 * 1024;
|
|
42
|
+
|
|
43
|
+
/** Stale upload expiry: 24 hours. */
|
|
44
|
+
const UPLOAD_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* TUS resumable upload handler.
|
|
48
|
+
*
|
|
49
|
+
* Each instance manages uploads for a single storage root. The
|
|
50
|
+
* `storageController` is used to finalize completed uploads by
|
|
51
|
+
* calling `putObject`.
|
|
52
|
+
*/
|
|
53
|
+
export class TusHandler {
|
|
54
|
+
private uploads = new Map<string, TusUpload>();
|
|
55
|
+
private tusDir: string;
|
|
56
|
+
private cleanupTimer?: ReturnType<typeof setInterval>;
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
storageBaseDir: string,
|
|
60
|
+
private storageController?: StorageController,
|
|
61
|
+
) {
|
|
62
|
+
this.tusDir = join(storageBaseDir, ".tus-uploads");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Ensure the temp directory exists. */
|
|
66
|
+
private async ensureDir(): Promise<void> {
|
|
67
|
+
if (!existsSync(this.tusDir)) {
|
|
68
|
+
await mkdir(this.tusDir, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Start periodic cleanup of stale uploads. */
|
|
73
|
+
startCleanup(): void {
|
|
74
|
+
if (this.cleanupTimer) return;
|
|
75
|
+
this.cleanupTimer = setInterval(() => {
|
|
76
|
+
void this.cleanupStale();
|
|
77
|
+
}, 60_000); // every minute
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Remove uploads that have been idle for longer than UPLOAD_EXPIRY_MS. */
|
|
81
|
+
private async cleanupStale(): Promise<void> {
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
for (const [id, upload] of this.uploads) {
|
|
84
|
+
if (now - upload.createdAt > UPLOAD_EXPIRY_MS && !upload.completed) {
|
|
85
|
+
try { await unlink(upload.filePath); } catch { /* ok */ }
|
|
86
|
+
this.uploads.delete(id);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// -----------------------------------------------------------------------
|
|
92
|
+
// TUS Metadata Parsing
|
|
93
|
+
// -----------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse the `Upload-Metadata` header.
|
|
97
|
+
*
|
|
98
|
+
* Format: `key base64value,key2 base64value2`
|
|
99
|
+
*/
|
|
100
|
+
private parseMetadata(header: string): Record<string, string> {
|
|
101
|
+
const metadata: Record<string, string> = {};
|
|
102
|
+
if (!header) return metadata;
|
|
103
|
+
for (const pair of header.split(",")) {
|
|
104
|
+
const trimmed = pair.trim();
|
|
105
|
+
const spaceIdx = trimmed.indexOf(" ");
|
|
106
|
+
if (spaceIdx === -1) {
|
|
107
|
+
metadata[trimmed] = "";
|
|
108
|
+
} else {
|
|
109
|
+
const key = trimmed.substring(0, spaceIdx);
|
|
110
|
+
const value = Buffer.from(trimmed.substring(spaceIdx + 1), "base64").toString("utf-8");
|
|
111
|
+
metadata[key] = value;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return metadata;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// -----------------------------------------------------------------------
|
|
118
|
+
// Protocol Endpoints
|
|
119
|
+
// -----------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
/** `OPTIONS /tus` — TUS capability discovery. */
|
|
122
|
+
options(): Response {
|
|
123
|
+
return new Response(null, {
|
|
124
|
+
status: 204,
|
|
125
|
+
headers: {
|
|
126
|
+
"Tus-Resumable": "1.0.0",
|
|
127
|
+
"Tus-Version": "1.0.0",
|
|
128
|
+
"Tus-Extension": "creation,termination",
|
|
129
|
+
"Tus-Max-Size": String(MAX_UPLOAD_SIZE),
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `POST /tus` — Create a new upload. */
|
|
135
|
+
async create(c: Context): Promise<Response> {
|
|
136
|
+
await this.ensureDir();
|
|
137
|
+
|
|
138
|
+
const uploadLengthHeader = c.req.header("Upload-Length");
|
|
139
|
+
if (!uploadLengthHeader) {
|
|
140
|
+
return c.json({ error: "Upload-Length header is required" }, 400);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const uploadLength = parseInt(uploadLengthHeader, 10);
|
|
144
|
+
if (Number.isNaN(uploadLength) || uploadLength <= 0) {
|
|
145
|
+
return c.json({ error: "Invalid Upload-Length" }, 400);
|
|
146
|
+
}
|
|
147
|
+
if (uploadLength > MAX_UPLOAD_SIZE) {
|
|
148
|
+
return c.json({ error: `Upload-Length exceeds maximum of ${MAX_UPLOAD_SIZE} bytes` }, 413);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const metadata = this.parseMetadata(c.req.header("Upload-Metadata") || "");
|
|
152
|
+
const id = randomUUID();
|
|
153
|
+
const filePath = join(this.tusDir, id);
|
|
154
|
+
|
|
155
|
+
// Create empty temp file
|
|
156
|
+
await writeFile(filePath, Buffer.alloc(0));
|
|
157
|
+
|
|
158
|
+
const upload: TusUpload = {
|
|
159
|
+
id,
|
|
160
|
+
size: uploadLength,
|
|
161
|
+
offset: 0,
|
|
162
|
+
metadata,
|
|
163
|
+
createdAt: Date.now(),
|
|
164
|
+
filePath,
|
|
165
|
+
bucket: metadata.bucket || undefined,
|
|
166
|
+
key: metadata.key || metadata.filename || undefined,
|
|
167
|
+
completed: false,
|
|
168
|
+
};
|
|
169
|
+
this.uploads.set(id, upload);
|
|
170
|
+
|
|
171
|
+
// Build absolute Location
|
|
172
|
+
const reqUrl = new URL(c.req.url);
|
|
173
|
+
const location = `${reqUrl.origin}${reqUrl.pathname}/${id}`;
|
|
174
|
+
|
|
175
|
+
return new Response(null, {
|
|
176
|
+
status: 201,
|
|
177
|
+
headers: {
|
|
178
|
+
Location: location,
|
|
179
|
+
"Tus-Resumable": "1.0.0",
|
|
180
|
+
"Upload-Offset": "0",
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** `HEAD /tus/:id` — Query upload progress. */
|
|
186
|
+
head(c: Context, id: string): Response {
|
|
187
|
+
const upload = this.uploads.get(id);
|
|
188
|
+
if (!upload) {
|
|
189
|
+
return c.json({ error: "Upload not found" }, 404);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return new Response(null, {
|
|
193
|
+
status: 200,
|
|
194
|
+
headers: {
|
|
195
|
+
"Tus-Resumable": "1.0.0",
|
|
196
|
+
"Upload-Offset": String(upload.offset),
|
|
197
|
+
"Upload-Length": String(upload.size),
|
|
198
|
+
"Cache-Control": "no-store",
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** `PATCH /tus/:id` — Append data to an upload. */
|
|
204
|
+
async patch(c: Context, id: string): Promise<Response> {
|
|
205
|
+
const upload = this.uploads.get(id);
|
|
206
|
+
if (!upload) {
|
|
207
|
+
return c.json({ error: "Upload not found" }, 404);
|
|
208
|
+
}
|
|
209
|
+
if (upload.completed) {
|
|
210
|
+
return c.json({ error: "Upload already completed" }, 400);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Validate offset
|
|
214
|
+
const offsetHeader = c.req.header("Upload-Offset");
|
|
215
|
+
if (!offsetHeader) {
|
|
216
|
+
return c.json({ error: "Upload-Offset header is required" }, 400);
|
|
217
|
+
}
|
|
218
|
+
const offset = parseInt(offsetHeader, 10);
|
|
219
|
+
if (offset !== upload.offset) {
|
|
220
|
+
return c.json({ error: "Offset mismatch" }, 409);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Validate content type
|
|
224
|
+
const contentType = c.req.header("Content-Type");
|
|
225
|
+
if (contentType !== "application/offset+octet-stream") {
|
|
226
|
+
return c.json({ error: "Content-Type must be application/offset+octet-stream" }, 415);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Read chunk and append to temp file
|
|
230
|
+
const body = await c.req.arrayBuffer();
|
|
231
|
+
const chunk = Buffer.from(body);
|
|
232
|
+
|
|
233
|
+
// Prevent overrun
|
|
234
|
+
if (upload.offset + chunk.length > upload.size) {
|
|
235
|
+
return c.json({ error: "Chunk exceeds declared Upload-Length" }, 413);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const fh = await open(upload.filePath, "a");
|
|
239
|
+
try {
|
|
240
|
+
await fh.write(chunk);
|
|
241
|
+
} finally {
|
|
242
|
+
await fh.close();
|
|
243
|
+
}
|
|
244
|
+
upload.offset += chunk.length;
|
|
245
|
+
|
|
246
|
+
// Finalize if complete
|
|
247
|
+
if (upload.offset >= upload.size) {
|
|
248
|
+
await this.finalize(upload);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return new Response(null, {
|
|
252
|
+
status: 204,
|
|
253
|
+
headers: {
|
|
254
|
+
"Tus-Resumable": "1.0.0",
|
|
255
|
+
"Upload-Offset": String(upload.offset),
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** `DELETE /tus/:id` — Cancel and remove an upload. */
|
|
261
|
+
async delete(c: Context, id: string): Promise<Response> {
|
|
262
|
+
const upload = this.uploads.get(id);
|
|
263
|
+
if (!upload) {
|
|
264
|
+
return c.json({ error: "Upload not found" }, 404);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try { await unlink(upload.filePath); } catch { /* ok */ }
|
|
268
|
+
this.uploads.delete(id);
|
|
269
|
+
|
|
270
|
+
return new Response(null, {
|
|
271
|
+
status: 204,
|
|
272
|
+
headers: { "Tus-Resumable": "1.0.0" },
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// -----------------------------------------------------------------------
|
|
277
|
+
// Finalization
|
|
278
|
+
// -----------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Move a completed upload into the storage controller.
|
|
282
|
+
*/
|
|
283
|
+
private async finalize(upload: TusUpload): Promise<void> {
|
|
284
|
+
upload.completed = true;
|
|
285
|
+
|
|
286
|
+
if (!this.storageController) {
|
|
287
|
+
// No controller — leave temp file in place
|
|
288
|
+
logger.warn("[TUS] Upload completed but no StorageController configured. Temp file remains:", { filePath: upload.filePath });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
const { readFile } = await import("fs/promises");
|
|
294
|
+
const data = await readFile(upload.filePath);
|
|
295
|
+
const fileName = upload.key || upload.metadata.filename || upload.id;
|
|
296
|
+
const mimeType = upload.metadata.contentType || upload.metadata.filetype || "application/octet-stream";
|
|
297
|
+
|
|
298
|
+
const file = new File([data], fileName, { type: mimeType });
|
|
299
|
+
|
|
300
|
+
await this.storageController.putObject({
|
|
301
|
+
file,
|
|
302
|
+
key: fileName,
|
|
303
|
+
bucket: upload.bucket,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Clean up temp file
|
|
307
|
+
try { await unlink(upload.filePath); } catch { /* ok */ }
|
|
308
|
+
this.uploads.delete(upload.id);
|
|
309
|
+
|
|
310
|
+
logger.info(`[TUS] Upload ${upload.id} finalized → ${fileName}`);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
logger.error(`[TUS] Failed to finalize upload ${upload.id}`, { error: err });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
package/src/utils/dev-port.ts
CHANGED
|
@@ -43,6 +43,20 @@ export function listenWithPortRetry(
|
|
|
43
43
|
const maxAttempts = options?.maxAttempts ?? MAX_PORT_ATTEMPTS;
|
|
44
44
|
const portFileDir = options?.portFileDir;
|
|
45
45
|
|
|
46
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
47
|
+
if (isProd) {
|
|
48
|
+
return new Promise<number>((resolve, reject) => {
|
|
49
|
+
const onError = (err: Error) => {
|
|
50
|
+
reject(err);
|
|
51
|
+
};
|
|
52
|
+
server.once("error", onError);
|
|
53
|
+
server.listen(startPort, host, () => {
|
|
54
|
+
server.removeListener("error", onError);
|
|
55
|
+
resolve(startPort);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
// Read affinity port from a previous run's port file.
|
|
47
61
|
// This ensures tsx watch restarts land on the same port the frontend was
|
|
48
62
|
// configured with, even if the CLI-computed port was different.
|
package/src/utils/logging.ts
CHANGED
|
@@ -16,13 +16,16 @@ debug: 3 };
|
|
|
16
16
|
if (currentLevel < 0) console.error = () => { };
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** Module-scoped backup of the original console methods. */
|
|
20
|
+
let originalConsole: Pick<Console, "log" | "warn" | "error" | "debug"> | undefined;
|
|
21
|
+
|
|
19
22
|
/**
|
|
20
23
|
* Reset console methods to their original state
|
|
21
24
|
*/
|
|
22
25
|
export function resetConsole() {
|
|
23
26
|
// Store original methods if not already stored
|
|
24
|
-
if (!
|
|
25
|
-
|
|
27
|
+
if (!originalConsole) {
|
|
28
|
+
originalConsole = {
|
|
26
29
|
log: console.log,
|
|
27
30
|
warn: console.warn,
|
|
28
31
|
error: console.error,
|
|
@@ -30,9 +33,8 @@ export function resetConsole() {
|
|
|
30
33
|
};
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
console.
|
|
35
|
-
console.
|
|
36
|
-
console.
|
|
37
|
-
console.debug = original.debug;
|
|
36
|
+
console.log = originalConsole.log;
|
|
37
|
+
console.warn = originalConsole.warn;
|
|
38
|
+
console.error = originalConsole.error;
|
|
39
|
+
console.debug = originalConsole.debug;
|
|
38
40
|
}
|