@voyant-travel/storage 0.104.1 → 0.106.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/dist/routes.d.ts +69 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +256 -0
- package/dist/routes.test.d.ts +2 -0
- package/dist/routes.test.d.ts.map +1 -0
- package/dist/routes.test.js +144 -0
- package/package.json +14 -3
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media upload + serve HTTP routes, owned by `@voyant-travel/storage`.
|
|
3
|
+
*
|
|
4
|
+
* agent-quality: file-size exception -- the media surface (multipart upload,
|
|
5
|
+
* video upload ticket, and hardened R2 serve) is one cohesive route family that
|
|
6
|
+
* shares the same content-type safety + key-parsing helpers; splitting it would
|
|
7
|
+
* scatter a single storage-backed contract.
|
|
8
|
+
*
|
|
9
|
+
* POST /v1/admin/uploads — multipart file upload → storage ticket
|
|
10
|
+
* POST /v1/admin/uploads/video — video upload ticket (deployment signer)
|
|
11
|
+
* GET /v1/admin/media/* — serve stored bytes (hardened)
|
|
12
|
+
*
|
|
13
|
+
* These routes register ABSOLUTE admin paths, so a deployment mounts the
|
|
14
|
+
* returned `Hono` at the app root rather than under a module prefix.
|
|
15
|
+
*
|
|
16
|
+
* The deployment supplies the storage-backed specifics via `options`:
|
|
17
|
+
* - `resolveStorage(c)` — the R2-backed `StorageProvider` for this request
|
|
18
|
+
* (or `null` when storage isn't configured → 503),
|
|
19
|
+
* - `signVideoUploadTicket(c, input)` — turn a validated video-upload request
|
|
20
|
+
* into a provider ticket (TUS / Cloudflare Stream / …),
|
|
21
|
+
* - `guessServedMimeType(key)` — best-effort MIME guess for the serve route.
|
|
22
|
+
*
|
|
23
|
+
* The package never imports the deployment's R2 binding or video provider.
|
|
24
|
+
*/
|
|
25
|
+
import { type Context, Hono } from "hono";
|
|
26
|
+
import { z } from "zod";
|
|
27
|
+
import type { StorageProvider } from "./types.js";
|
|
28
|
+
/** Validated body shape for a video upload ticket request. */
|
|
29
|
+
export declare const videoUploadTicketBodySchema: z.ZodObject<{
|
|
30
|
+
fileSize: z.ZodNumber;
|
|
31
|
+
maxDurationSeconds: z.ZodNumber;
|
|
32
|
+
name: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
33
|
+
requireSignedUrls: z.ZodOptional<z.ZodBoolean>;
|
|
34
|
+
allowedOrigins: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
35
|
+
thumbnailTimestampPct: z.ZodNullable<z.ZodOptional<z.ZodNumber>>;
|
|
36
|
+
meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
37
|
+
}, z.core.$strip>;
|
|
38
|
+
export type VideoUploadTicketRequest = z.infer<typeof videoUploadTicketBodySchema>;
|
|
39
|
+
/**
|
|
40
|
+
* Deployment-supplied options for the media route module. Structural only —
|
|
41
|
+
* the injected functions encapsulate the deployment's R2 binding and video
|
|
42
|
+
* provider so this package stays free of those static imports.
|
|
43
|
+
*/
|
|
44
|
+
export interface MediaRoutesOptions {
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the storage provider for this request, or `null` when storage
|
|
47
|
+
* isn't configured (the upload/serve routes then respond `503`).
|
|
48
|
+
*/
|
|
49
|
+
resolveStorage(c: Context): StorageProvider | null;
|
|
50
|
+
/**
|
|
51
|
+
* Turn a validated video-upload request into a provider ticket. The
|
|
52
|
+
* deployment owns the provider (Cloudflare Stream / any TUS host).
|
|
53
|
+
*/
|
|
54
|
+
signVideoUploadTicket(c: Context, input: VideoUploadTicketRequest): Promise<unknown>;
|
|
55
|
+
/**
|
|
56
|
+
* Best-effort MIME guess from a stored object key, used by the serve route.
|
|
57
|
+
* Defaults to {@link guessMimeType}.
|
|
58
|
+
*/
|
|
59
|
+
guessServedMimeType?(key: string): string;
|
|
60
|
+
}
|
|
61
|
+
/** Best-effort MIME type guess from a file key/path. Used by the serve route. */
|
|
62
|
+
export declare function guessMimeType(key: string): string;
|
|
63
|
+
/**
|
|
64
|
+
* Build the media upload + serve routes (ABSOLUTE paths; mount at the app
|
|
65
|
+
* root). The deployment supplies the storage provider + video signer via
|
|
66
|
+
* `options`.
|
|
67
|
+
*/
|
|
68
|
+
export declare function createMediaRoutes(options: MediaRoutesOptions): Hono;
|
|
69
|
+
//# sourceMappingURL=routes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAE,KAAK,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AACzC,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAyBjD,8DAA8D;AAC9D,eAAO,MAAM,2BAA2B;;;;;;;;iBAQtC,CAAA;AAEF,MAAM,MAAM,wBAAwB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,2BAA2B,CAAC,CAAA;AAMlF;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,cAAc,CAAC,CAAC,EAAE,OAAO,GAAG,eAAe,GAAG,IAAI,CAAA;IAClD;;;OAGG;IACH,qBAAqB,CAAC,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,wBAAwB,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IACpF;;;OAGG;IACH,mBAAmB,CAAC,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;CAC1C;AA4BD,iFAAiF;AACjF,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAGjD;AAiGD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,IAAI,CAkGnE"}
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media upload + serve HTTP routes, owned by `@voyant-travel/storage`.
|
|
3
|
+
*
|
|
4
|
+
* agent-quality: file-size exception -- the media surface (multipart upload,
|
|
5
|
+
* video upload ticket, and hardened R2 serve) is one cohesive route family that
|
|
6
|
+
* shares the same content-type safety + key-parsing helpers; splitting it would
|
|
7
|
+
* scatter a single storage-backed contract.
|
|
8
|
+
*
|
|
9
|
+
* POST /v1/admin/uploads — multipart file upload → storage ticket
|
|
10
|
+
* POST /v1/admin/uploads/video — video upload ticket (deployment signer)
|
|
11
|
+
* GET /v1/admin/media/* — serve stored bytes (hardened)
|
|
12
|
+
*
|
|
13
|
+
* These routes register ABSOLUTE admin paths, so a deployment mounts the
|
|
14
|
+
* returned `Hono` at the app root rather than under a module prefix.
|
|
15
|
+
*
|
|
16
|
+
* The deployment supplies the storage-backed specifics via `options`:
|
|
17
|
+
* - `resolveStorage(c)` — the R2-backed `StorageProvider` for this request
|
|
18
|
+
* (or `null` when storage isn't configured → 503),
|
|
19
|
+
* - `signVideoUploadTicket(c, input)` — turn a validated video-upload request
|
|
20
|
+
* into a provider ticket (TUS / Cloudflare Stream / …),
|
|
21
|
+
* - `guessServedMimeType(key)` — best-effort MIME guess for the serve route.
|
|
22
|
+
*
|
|
23
|
+
* The package never imports the deployment's R2 binding or video provider.
|
|
24
|
+
*/
|
|
25
|
+
import { Hono } from "hono";
|
|
26
|
+
import { z } from "zod";
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────
|
|
28
|
+
// Tuning constants (preserved byte-for-byte from the operator origin)
|
|
29
|
+
// ─────────────────────────────────────────────────────────────────
|
|
30
|
+
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
31
|
+
const MAX_MULTIPART_UPLOAD_BYTES = MAX_UPLOAD_BYTES + 1024 * 1024;
|
|
32
|
+
const MAX_VIDEO_UPLOAD_BYTES = 1024 * 1024 * 1024;
|
|
33
|
+
const MAX_VIDEO_DURATION_SECONDS = 12 * 60 * 60;
|
|
34
|
+
const ALLOWED_MEDIA_KEY_PREFIXES = ["uploads/", "brochures/products/"];
|
|
35
|
+
const UNSAFE_UPLOAD_EXTENSIONS = new Set(["cjs", "htm", "html", "js", "mjs", "svg", "xhtml", "xml"]);
|
|
36
|
+
const SCRIPTABLE_MIME_TYPES = new Set([
|
|
37
|
+
"application/ecmascript",
|
|
38
|
+
"application/javascript",
|
|
39
|
+
"application/xhtml+xml",
|
|
40
|
+
"application/xml",
|
|
41
|
+
"image/svg+xml",
|
|
42
|
+
"text/ecmascript",
|
|
43
|
+
"text/html",
|
|
44
|
+
"text/javascript",
|
|
45
|
+
"text/xml",
|
|
46
|
+
]);
|
|
47
|
+
/** Validated body shape for a video upload ticket request. */
|
|
48
|
+
export const videoUploadTicketBodySchema = z.object({
|
|
49
|
+
fileSize: z.number().int().positive().max(MAX_VIDEO_UPLOAD_BYTES),
|
|
50
|
+
maxDurationSeconds: z.number().int().positive().max(MAX_VIDEO_DURATION_SECONDS),
|
|
51
|
+
name: z.string().trim().min(1).max(255).optional().nullable(),
|
|
52
|
+
requireSignedUrls: z.boolean().optional(),
|
|
53
|
+
allowedOrigins: z.array(z.string().trim().min(1).max(2048)).max(20).optional(),
|
|
54
|
+
thumbnailTimestampPct: z.number().min(0).max(1).optional().nullable(),
|
|
55
|
+
meta: z.record(z.string().max(128), z.string().max(2048)).optional(),
|
|
56
|
+
});
|
|
57
|
+
// ─────────────────────────────────────────────────────────────────
|
|
58
|
+
// Default MIME guessing (so a deployment can omit `guessServedMimeType`)
|
|
59
|
+
// ─────────────────────────────────────────────────────────────────
|
|
60
|
+
const MIME_BY_EXT = {
|
|
61
|
+
pdf: "application/pdf",
|
|
62
|
+
png: "image/png",
|
|
63
|
+
jpg: "image/jpeg",
|
|
64
|
+
jpeg: "image/jpeg",
|
|
65
|
+
gif: "image/gif",
|
|
66
|
+
webp: "image/webp",
|
|
67
|
+
svg: "image/svg+xml",
|
|
68
|
+
mp4: "video/mp4",
|
|
69
|
+
webm: "video/webm",
|
|
70
|
+
mov: "video/quicktime",
|
|
71
|
+
txt: "text/plain",
|
|
72
|
+
csv: "text/csv",
|
|
73
|
+
json: "application/json",
|
|
74
|
+
xml: "application/xml",
|
|
75
|
+
zip: "application/zip",
|
|
76
|
+
doc: "application/msword",
|
|
77
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
78
|
+
xls: "application/vnd.ms-excel",
|
|
79
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
80
|
+
};
|
|
81
|
+
/** Best-effort MIME type guess from a file key/path. Used by the serve route. */
|
|
82
|
+
export function guessMimeType(key) {
|
|
83
|
+
const ext = key.split(".").pop()?.toLowerCase() ?? "";
|
|
84
|
+
return MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
85
|
+
}
|
|
86
|
+
// ─────────────────────────────────────────────────────────────────
|
|
87
|
+
// Pure helpers (preserved byte-for-byte from the operator origin)
|
|
88
|
+
// ─────────────────────────────────────────────────────────────────
|
|
89
|
+
function maybeString(value) {
|
|
90
|
+
const trimmed = value?.trim();
|
|
91
|
+
return trimmed && trimmed.length > 0 ? trimmed : null;
|
|
92
|
+
}
|
|
93
|
+
function safeAsciiFilename(filename) {
|
|
94
|
+
const normalized = maybeString(filename);
|
|
95
|
+
if (!normalized)
|
|
96
|
+
return "download";
|
|
97
|
+
const safe = normalized
|
|
98
|
+
.normalize("NFKD")
|
|
99
|
+
.replace(/[^\w .-]+/g, "-")
|
|
100
|
+
.replace(/[\r\n"\\]+/g, "-")
|
|
101
|
+
.replace(/\s+/g, " ")
|
|
102
|
+
.trim()
|
|
103
|
+
.slice(0, 160);
|
|
104
|
+
return safe || "download";
|
|
105
|
+
}
|
|
106
|
+
function contentDispositionForKey(key) {
|
|
107
|
+
return `attachment; filename="${safeAsciiFilename(key.split("/").filter(Boolean).at(-1))}"`;
|
|
108
|
+
}
|
|
109
|
+
function getContentLength(c) {
|
|
110
|
+
const raw = c.req.header("content-length");
|
|
111
|
+
if (!raw)
|
|
112
|
+
return null;
|
|
113
|
+
const parsed = Number.parseInt(raw, 10);
|
|
114
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
115
|
+
}
|
|
116
|
+
function normalizedMimeType(contentType) {
|
|
117
|
+
const value = maybeString(contentType);
|
|
118
|
+
if (!value)
|
|
119
|
+
return null;
|
|
120
|
+
return value.split(";")[0]?.trim().toLowerCase() || null;
|
|
121
|
+
}
|
|
122
|
+
function isScriptableMimeType(contentType) {
|
|
123
|
+
const normalized = normalizedMimeType(contentType);
|
|
124
|
+
return normalized ? SCRIPTABLE_MIME_TYPES.has(normalized) : false;
|
|
125
|
+
}
|
|
126
|
+
function extensionFromFilename(filename) {
|
|
127
|
+
const lastSegment = filename.split(/[\\/]/).at(-1) ?? filename;
|
|
128
|
+
const ext = lastSegment.includes(".") ? lastSegment.split(".").pop() : undefined;
|
|
129
|
+
const normalized = ext?.trim().toLowerCase();
|
|
130
|
+
return normalized && /^[a-z0-9]{1,16}$/.test(normalized) ? normalized : "bin";
|
|
131
|
+
}
|
|
132
|
+
function safeUploadContentType(file) {
|
|
133
|
+
const contentType = normalizedMimeType(file.type);
|
|
134
|
+
if (!contentType || isScriptableMimeType(contentType)) {
|
|
135
|
+
return "application/octet-stream";
|
|
136
|
+
}
|
|
137
|
+
return contentType;
|
|
138
|
+
}
|
|
139
|
+
function parseMediaKey(path) {
|
|
140
|
+
const rawKey = path.replace(/^\/v1\/(?:admin\/)?media\//, "");
|
|
141
|
+
if (!rawKey)
|
|
142
|
+
return null;
|
|
143
|
+
const segments = [];
|
|
144
|
+
for (const rawSegment of rawKey.split("/")) {
|
|
145
|
+
if (!rawSegment)
|
|
146
|
+
return null;
|
|
147
|
+
let segment;
|
|
148
|
+
try {
|
|
149
|
+
segment = decodeURIComponent(rawSegment);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
if (!segment ||
|
|
155
|
+
segment === "." ||
|
|
156
|
+
segment === ".." ||
|
|
157
|
+
segment.includes("/") ||
|
|
158
|
+
segment.includes("\\") ||
|
|
159
|
+
segment.includes("\0")) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
segments.push(segment);
|
|
163
|
+
}
|
|
164
|
+
const key = segments.join("/");
|
|
165
|
+
return ALLOWED_MEDIA_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) ? key : null;
|
|
166
|
+
}
|
|
167
|
+
// ─────────────────────────────────────────────────────────────────
|
|
168
|
+
// Routes
|
|
169
|
+
// ─────────────────────────────────────────────────────────────────
|
|
170
|
+
/**
|
|
171
|
+
* Build the media upload + serve routes (ABSOLUTE paths; mount at the app
|
|
172
|
+
* root). The deployment supplies the storage provider + video signer via
|
|
173
|
+
* `options`.
|
|
174
|
+
*/
|
|
175
|
+
export function createMediaRoutes(options) {
|
|
176
|
+
const hono = new Hono();
|
|
177
|
+
const guessServedMimeType = options.guessServedMimeType ?? guessMimeType;
|
|
178
|
+
function safeServedContentType(key) {
|
|
179
|
+
const guessed = guessServedMimeType(key);
|
|
180
|
+
return isScriptableMimeType(guessed) ? "application/octet-stream" : guessed;
|
|
181
|
+
}
|
|
182
|
+
const handleUpload = async (c) => {
|
|
183
|
+
const storage = options.resolveStorage(c);
|
|
184
|
+
if (!storage) {
|
|
185
|
+
return c.json({ error: "Storage not configured" }, 503);
|
|
186
|
+
}
|
|
187
|
+
const contentLength = getContentLength(c);
|
|
188
|
+
if (contentLength !== null && contentLength > MAX_MULTIPART_UPLOAD_BYTES) {
|
|
189
|
+
return c.json({ error: `Upload is too large; limit is ${MAX_UPLOAD_BYTES} bytes` }, 413);
|
|
190
|
+
}
|
|
191
|
+
const body = await c.req.parseBody();
|
|
192
|
+
const file = body.file;
|
|
193
|
+
if (!(file instanceof File)) {
|
|
194
|
+
return c.json({ error: "Missing file field in multipart body" }, 400);
|
|
195
|
+
}
|
|
196
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
197
|
+
return c.json({ error: `Upload is too large; limit is ${MAX_UPLOAD_BYTES} bytes` }, 413);
|
|
198
|
+
}
|
|
199
|
+
const ext = extensionFromFilename(file.name);
|
|
200
|
+
if (UNSAFE_UPLOAD_EXTENSIONS.has(ext) || isScriptableMimeType(file.type)) {
|
|
201
|
+
return c.json({ error: "Unsupported upload file type" }, 415);
|
|
202
|
+
}
|
|
203
|
+
const key = `uploads/${Date.now()}-${crypto.randomUUID().slice(0, 8)}.${ext}`;
|
|
204
|
+
const contentType = safeUploadContentType(file);
|
|
205
|
+
const result = await storage.upload(await file.arrayBuffer(), {
|
|
206
|
+
key,
|
|
207
|
+
contentType,
|
|
208
|
+
});
|
|
209
|
+
return c.json({
|
|
210
|
+
key: result.key,
|
|
211
|
+
url: result.url,
|
|
212
|
+
mimeType: contentType,
|
|
213
|
+
size: file.size,
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
hono.post("/v1/admin/uploads", handleUpload);
|
|
217
|
+
const handleVideoUploadTicket = async (c) => {
|
|
218
|
+
let raw;
|
|
219
|
+
try {
|
|
220
|
+
raw = await c.req.json();
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
224
|
+
}
|
|
225
|
+
const parsed = videoUploadTicketBodySchema.safeParse(raw);
|
|
226
|
+
if (!parsed.success) {
|
|
227
|
+
return c.json({ error: "Invalid request", issues: parsed.error.issues }, 400);
|
|
228
|
+
}
|
|
229
|
+
const ticket = await options.signVideoUploadTicket(c, parsed.data);
|
|
230
|
+
return c.json(ticket);
|
|
231
|
+
};
|
|
232
|
+
hono.post("/v1/admin/uploads/video", handleVideoUploadTicket);
|
|
233
|
+
const handleMediaServe = async (c) => {
|
|
234
|
+
const storage = options.resolveStorage(c);
|
|
235
|
+
if (!storage) {
|
|
236
|
+
return c.json({ error: "Storage not configured" }, 503);
|
|
237
|
+
}
|
|
238
|
+
const key = parseMediaKey(c.req.path);
|
|
239
|
+
if (!key) {
|
|
240
|
+
return c.json({ error: "Invalid media key" }, 400);
|
|
241
|
+
}
|
|
242
|
+
const buffer = await storage.get(key);
|
|
243
|
+
if (!buffer) {
|
|
244
|
+
return c.json({ error: "Not found" }, 404);
|
|
245
|
+
}
|
|
246
|
+
const headers = new Headers();
|
|
247
|
+
headers.set("Content-Type", safeServedContentType(key));
|
|
248
|
+
headers.set("X-Content-Type-Options", "nosniff");
|
|
249
|
+
headers.set("Content-Disposition", contentDispositionForKey(key));
|
|
250
|
+
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
251
|
+
headers.set("Content-Length", String(buffer.byteLength));
|
|
252
|
+
return new Response(buffer, { headers });
|
|
253
|
+
};
|
|
254
|
+
hono.get("/v1/admin/media/*", handleMediaServe);
|
|
255
|
+
return hono;
|
|
256
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.test.d.ts","sourceRoot":"","sources":["../src/routes.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createMediaRoutes } from "./routes.js";
|
|
5
|
+
function makeStorage(overrides) {
|
|
6
|
+
return {
|
|
7
|
+
name: "stub",
|
|
8
|
+
upload: vi.fn(async () => ({ key: "k", url: "/u" })),
|
|
9
|
+
delete: vi.fn(async () => { }),
|
|
10
|
+
signedUrl: vi.fn(async () => "/signed"),
|
|
11
|
+
get: vi.fn(async () => null),
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function mountApp(options) {
|
|
16
|
+
const app = new Hono();
|
|
17
|
+
app.route("/", createMediaRoutes({
|
|
18
|
+
resolveStorage: options.resolveStorage ?? (() => null),
|
|
19
|
+
signVideoUploadTicket: options.signVideoUploadTicket ??
|
|
20
|
+
(async () => ({ uploadUrl: "https://uploads.example/video" })),
|
|
21
|
+
...(options.guessServedMimeType ? { guessServedMimeType: options.guessServedMimeType } : {}),
|
|
22
|
+
}));
|
|
23
|
+
return app;
|
|
24
|
+
}
|
|
25
|
+
function multipartFileBody(options) {
|
|
26
|
+
return [
|
|
27
|
+
`--${options.boundary}`,
|
|
28
|
+
`Content-Disposition: form-data; name="file"; filename="${options.filename}"`,
|
|
29
|
+
`Content-Type: ${options.contentType}`,
|
|
30
|
+
"",
|
|
31
|
+
options.body,
|
|
32
|
+
`--${options.boundary}--`,
|
|
33
|
+
"",
|
|
34
|
+
].join("\r\n");
|
|
35
|
+
}
|
|
36
|
+
describe("media routes", () => {
|
|
37
|
+
it("streams stored media by allowed key as an attachment", async () => {
|
|
38
|
+
const storage = makeStorage({ get: vi.fn(async () => new TextEncoder().encode("pdf").buffer) });
|
|
39
|
+
const app = mountApp({ resolveStorage: () => storage });
|
|
40
|
+
const response = await app.request("/v1/admin/media/brochures/products/example.pdf");
|
|
41
|
+
expect(response.status).toBe(200);
|
|
42
|
+
expect(response.headers.get("content-type")).toBe("application/pdf");
|
|
43
|
+
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
|
44
|
+
expect(response.headers.get("content-disposition")).toBe('attachment; filename="example.pdf"');
|
|
45
|
+
await expect(response.text()).resolves.toBe("pdf");
|
|
46
|
+
});
|
|
47
|
+
it("rejects media keys outside allowed upload prefixes", async () => {
|
|
48
|
+
const get = vi.fn(async () => new TextEncoder().encode("private").buffer);
|
|
49
|
+
const app = mountApp({ resolveStorage: () => makeStorage({ get }) });
|
|
50
|
+
const response = await app.request("/v1/admin/media/private/example.pdf");
|
|
51
|
+
expect(response.status).toBe(400);
|
|
52
|
+
expect(get).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
it("forces scriptable stored media to octet-stream", async () => {
|
|
55
|
+
const storage = makeStorage({
|
|
56
|
+
get: vi.fn(async () => new TextEncoder().encode("<svg />").buffer),
|
|
57
|
+
});
|
|
58
|
+
const app = mountApp({ resolveStorage: () => storage });
|
|
59
|
+
const response = await app.request("/v1/admin/media/uploads/evil.svg");
|
|
60
|
+
expect(response.status).toBe(200);
|
|
61
|
+
expect(response.headers.get("content-type")).toBe("application/octet-stream");
|
|
62
|
+
expect(response.headers.get("content-disposition")).toBe('attachment; filename="evil.svg"');
|
|
63
|
+
});
|
|
64
|
+
it("serves media through the admin surface with the same hardening", async () => {
|
|
65
|
+
const get = vi.fn(async () => new TextEncoder().encode("pdf").buffer);
|
|
66
|
+
const app = mountApp({ resolveStorage: () => makeStorage({ get }) });
|
|
67
|
+
const response = await app.request("/v1/admin/media/uploads/example.pdf");
|
|
68
|
+
expect(response.status).toBe(200);
|
|
69
|
+
expect(get).toHaveBeenCalledWith("uploads/example.pdf");
|
|
70
|
+
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
|
71
|
+
expect(response.headers.get("content-disposition")).toBe('attachment; filename="example.pdf"');
|
|
72
|
+
});
|
|
73
|
+
it("responds 503 when storage is unconfigured", async () => {
|
|
74
|
+
const app = mountApp({ resolveStorage: () => null });
|
|
75
|
+
const response = await app.request("/v1/admin/media/uploads/example.pdf");
|
|
76
|
+
expect(response.status).toBe(503);
|
|
77
|
+
});
|
|
78
|
+
it("rejects unsafe upload types", async () => {
|
|
79
|
+
const upload = vi.fn();
|
|
80
|
+
const app = mountApp({ resolveStorage: () => makeStorage({ upload }) });
|
|
81
|
+
const boundary = "----voyant-test-boundary";
|
|
82
|
+
const response = await app.request("/v1/admin/uploads", {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
|
85
|
+
body: multipartFileBody({
|
|
86
|
+
boundary,
|
|
87
|
+
filename: "evil.svg",
|
|
88
|
+
contentType: "image/svg+xml",
|
|
89
|
+
body: "<svg />",
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
expect(response.status).toBe(415);
|
|
93
|
+
expect(upload).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
it("accepts bounded uploads on the admin surface", async () => {
|
|
96
|
+
const upload = vi.fn(async (_body, options) => ({
|
|
97
|
+
key: options.key,
|
|
98
|
+
url: `/api/v1/admin/media/${options.key}`,
|
|
99
|
+
}));
|
|
100
|
+
const app = mountApp({ resolveStorage: () => makeStorage({ upload }) });
|
|
101
|
+
const boundary = "----voyant-test-boundary";
|
|
102
|
+
const response = await app.request("/v1/admin/uploads", {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
|
105
|
+
body: multipartFileBody({
|
|
106
|
+
boundary,
|
|
107
|
+
filename: "photo.png",
|
|
108
|
+
contentType: "image/png",
|
|
109
|
+
body: "png",
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
expect(response.status).toBe(200);
|
|
113
|
+
expect(upload).toHaveBeenCalledOnce();
|
|
114
|
+
expect(upload.mock.calls[0]?.[1]).toMatchObject({ contentType: "image/png" });
|
|
115
|
+
const body = (await response.json());
|
|
116
|
+
expect(body.key).toMatch(/^uploads\/\d+-[a-f0-9-]+\.png$/);
|
|
117
|
+
expect(body.mimeType).toBe("image/png");
|
|
118
|
+
expect(body.size).toBe(3);
|
|
119
|
+
});
|
|
120
|
+
it("validates video upload ticket requests on the admin surface", async () => {
|
|
121
|
+
const signVideoUploadTicket = vi.fn(async () => ({
|
|
122
|
+
uploadUrl: "https://uploads.example/video",
|
|
123
|
+
}));
|
|
124
|
+
const app = mountApp({ signVideoUploadTicket });
|
|
125
|
+
const response = await app.request("/v1/admin/uploads/video", {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { "content-type": "application/json" },
|
|
128
|
+
body: JSON.stringify({ fileSize: 1024, maxDurationSeconds: 60, name: "clip.mp4" }),
|
|
129
|
+
});
|
|
130
|
+
expect(response.status).toBe(200);
|
|
131
|
+
expect(signVideoUploadTicket).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ fileSize: 1024, maxDurationSeconds: 60, name: "clip.mp4" }));
|
|
132
|
+
});
|
|
133
|
+
it("rejects an invalid video upload ticket body", async () => {
|
|
134
|
+
const signVideoUploadTicket = vi.fn();
|
|
135
|
+
const app = mountApp({ signVideoUploadTicket });
|
|
136
|
+
const response = await app.request("/v1/admin/uploads/video", {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { "content-type": "application/json" },
|
|
139
|
+
body: JSON.stringify({ fileSize: -1, maxDurationSeconds: 60 }),
|
|
140
|
+
});
|
|
141
|
+
expect(response.status).toBe(400);
|
|
142
|
+
expect(signVideoUploadTicket).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voyant-travel/storage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.106.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
"import": "./dist/service.js",
|
|
20
20
|
"default": "./dist/service.js"
|
|
21
21
|
},
|
|
22
|
+
"./routes": {
|
|
23
|
+
"types": "./dist/routes.d.ts",
|
|
24
|
+
"import": "./dist/routes.js",
|
|
25
|
+
"default": "./dist/routes.js"
|
|
26
|
+
},
|
|
22
27
|
"./providers/local": {
|
|
23
28
|
"types": "./dist/providers/local.d.ts",
|
|
24
29
|
"import": "./dist/providers/local.js",
|
|
@@ -47,9 +52,15 @@
|
|
|
47
52
|
"access": "public"
|
|
48
53
|
},
|
|
49
54
|
"dependencies": {},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"hono": "^4.12.10",
|
|
57
|
+
"zod": "^4.3.6"
|
|
58
|
+
},
|
|
50
59
|
"devDependencies": {
|
|
60
|
+
"hono": "^4.12.10",
|
|
51
61
|
"typescript": "^6.0.2",
|
|
52
62
|
"vitest": "^4.1.2",
|
|
63
|
+
"zod": "^4.3.6",
|
|
53
64
|
"@voyant-travel/voyant-typescript-config": "^0.1.0"
|
|
54
65
|
},
|
|
55
66
|
"repository": {
|
|
@@ -58,10 +69,10 @@
|
|
|
58
69
|
"directory": "packages/storage"
|
|
59
70
|
},
|
|
60
71
|
"scripts": {
|
|
61
|
-
"typecheck": "tsc
|
|
72
|
+
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
62
73
|
"lint": "biome check src/",
|
|
63
74
|
"test": "vitest run",
|
|
64
|
-
"build": "tsc -p tsconfig.json",
|
|
75
|
+
"build": "tsc -p tsconfig.build.json",
|
|
65
76
|
"clean": "rm -rf dist tsconfig.tsbuildinfo"
|
|
66
77
|
},
|
|
67
78
|
"main": "./dist/index.js",
|