@plumix/plugin-media 0.1.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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +211 -0
  3. package/dist/admin/FocalPointField.d.ts +22 -0
  4. package/dist/admin/FocalPointField.d.ts.map +1 -0
  5. package/dist/admin/FocalPointField.js +71 -0
  6. package/dist/admin/FocalPointField.js.map +1 -0
  7. package/dist/admin/MediaLibrary.d.ts +43 -0
  8. package/dist/admin/MediaLibrary.d.ts.map +1 -0
  9. package/dist/admin/MediaLibrary.js +698 -0
  10. package/dist/admin/MediaLibrary.js.map +1 -0
  11. package/dist/admin/MediaListPickerField.d.ts +14 -0
  12. package/dist/admin/MediaListPickerField.d.ts.map +1 -0
  13. package/dist/admin/MediaListPickerField.js +161 -0
  14. package/dist/admin/MediaListPickerField.js.map +1 -0
  15. package/dist/admin/MediaPickerField.d.ts +25 -0
  16. package/dist/admin/MediaPickerField.d.ts.map +1 -0
  17. package/dist/admin/MediaPickerField.js +82 -0
  18. package/dist/admin/MediaPickerField.js.map +1 -0
  19. package/dist/admin/MediaUrlField.d.ts +14 -0
  20. package/dist/admin/MediaUrlField.d.ts.map +1 -0
  21. package/dist/admin/MediaUrlField.js +29 -0
  22. package/dist/admin/MediaUrlField.js.map +1 -0
  23. package/dist/admin/index.d.ts +11 -0
  24. package/dist/admin/index.d.ts.map +1 -0
  25. package/dist/admin/index.js +49 -0
  26. package/dist/admin/index.js.map +1 -0
  27. package/dist/admin/messages.d.ts +24 -0
  28. package/dist/admin/messages.d.ts.map +1 -0
  29. package/dist/admin/messages.js +28 -0
  30. package/dist/admin/messages.js.map +1 -0
  31. package/dist/blocks/file/index.d.ts +2 -0
  32. package/dist/blocks/file/index.d.ts.map +1 -0
  33. package/dist/blocks/file/index.js +109 -0
  34. package/dist/blocks/file/index.js.map +1 -0
  35. package/dist/blocks/file/normalize.d.ts +10 -0
  36. package/dist/blocks/file/normalize.d.ts.map +1 -0
  37. package/dist/blocks/file/normalize.js +45 -0
  38. package/dist/blocks/file/normalize.js.map +1 -0
  39. package/dist/blocks/image/index.d.ts +2 -0
  40. package/dist/blocks/image/index.d.ts.map +1 -0
  41. package/dist/blocks/image/index.js +141 -0
  42. package/dist/blocks/image/index.js.map +1 -0
  43. package/dist/blocks/image/normalize.d.ts +7 -0
  44. package/dist/blocks/image/normalize.d.ts.map +1 -0
  45. package/dist/blocks/image/normalize.js +20 -0
  46. package/dist/blocks/image/normalize.js.map +1 -0
  47. package/dist/blocks.d.ts +2 -0
  48. package/dist/blocks.d.ts.map +1 -0
  49. package/dist/blocks.js +6 -0
  50. package/dist/blocks.js.map +1 -0
  51. package/dist/builder.d.ts +80 -0
  52. package/dist/builder.d.ts.map +1 -0
  53. package/dist/builder.js +61 -0
  54. package/dist/builder.js.map +1 -0
  55. package/dist/fields.d.ts +3 -0
  56. package/dist/fields.d.ts.map +1 -0
  57. package/dist/fields.js +7 -0
  58. package/dist/fields.js.map +1 -0
  59. package/dist/image-dimensions.d.ts +7 -0
  60. package/dist/image-dimensions.d.ts.map +1 -0
  61. package/dist/image-dimensions.js +142 -0
  62. package/dist/image-dimensions.js.map +1 -0
  63. package/dist/index.d.ts +62 -0
  64. package/dist/index.d.ts.map +1 -0
  65. package/dist/index.js +216 -0
  66. package/dist/index.js.map +1 -0
  67. package/dist/lookup.d.ts +43 -0
  68. package/dist/lookup.d.ts.map +1 -0
  69. package/dist/lookup.js +154 -0
  70. package/dist/lookup.js.map +1 -0
  71. package/dist/magic-bytes.d.ts +4 -0
  72. package/dist/magic-bytes.d.ts.map +1 -0
  73. package/dist/magic-bytes.js +107 -0
  74. package/dist/magic-bytes.js.map +1 -0
  75. package/dist/mcp-tools.d.ts +10 -0
  76. package/dist/mcp-tools.d.ts.map +1 -0
  77. package/dist/mcp-tools.js +43 -0
  78. package/dist/mcp-tools.js.map +1 -0
  79. package/dist/media-blocks.d.ts +3 -0
  80. package/dist/media-blocks.d.ts.map +1 -0
  81. package/dist/media-blocks.js +10 -0
  82. package/dist/media-blocks.js.map +1 -0
  83. package/dist/meta.d.ts +12 -0
  84. package/dist/meta.d.ts.map +1 -0
  85. package/dist/meta.js +20 -0
  86. package/dist/meta.js.map +1 -0
  87. package/dist/mime.d.ts +3 -0
  88. package/dist/mime.d.ts.map +1 -0
  89. package/dist/mime.js +58 -0
  90. package/dist/mime.js.map +1 -0
  91. package/dist/read-service.d.ts +66 -0
  92. package/dist/read-service.d.ts.map +1 -0
  93. package/dist/read-service.js +156 -0
  94. package/dist/read-service.js.map +1 -0
  95. package/dist/rpc.d.ts +7 -0
  96. package/dist/rpc.d.ts.map +1 -0
  97. package/dist/rpc.js +352 -0
  98. package/dist/rpc.js.map +1 -0
  99. package/dist/serve-route.d.ts +19 -0
  100. package/dist/serve-route.d.ts.map +1 -0
  101. package/dist/serve-route.js +122 -0
  102. package/dist/serve-route.js.map +1 -0
  103. package/dist/upload-route.d.ts +17 -0
  104. package/dist/upload-route.d.ts.map +1 -0
  105. package/dist/upload-route.js +106 -0
  106. package/dist/upload-route.js.map +1 -0
  107. package/package.json +97 -0
package/dist/rpc.js ADDED
@@ -0,0 +1,352 @@
1
+ import { and, authenticated, base, entries, eq, withBasePath, } from "plumix/plugin";
2
+ import * as v from "valibot";
3
+ import { DIMENSION_SAMPLE_SIZE, readImageDimensions, } from "./image-dimensions.js";
4
+ import { looksLikeMime, MAGIC_BYTE_SAMPLE_SIZE } from "./magic-bytes.js";
5
+ import { parseMediaMeta } from "./meta.js";
6
+ import { extensionForMime } from "./mime.js";
7
+ import { listMedia, MEDIA_ENTRY_TYPE, mediaListInputSchema, MediaReadError, resolveMediaUrl, thumbnailFor, } from "./read-service.js";
8
+ // Reject control characters and whitespace. Browsers send a bare
9
+ // `Content-Type: image/png` (no space, no parameters) when uploading
10
+ // a File via XHR; anything else here is a sign of a malformed client.
11
+ const CONTENT_TYPE_RE = /^[\x21-\x7E]+$/;
12
+ /**
13
+ * Load a media-entry row and verify the caller is either the owner or
14
+ * holds the supplied capability. Used by `update` and `remove`, which
15
+ * share the same load + ownership pattern but differ on the capability
16
+ * a non-owner needs.
17
+ */
18
+ async function loadOwnedMediaRow(context, id, options) {
19
+ const { capability, errors } = options;
20
+ const notFound = () => errors.NOT_FOUND({ data: { kind: "media", id } });
21
+ const [row] = await context.db
22
+ .select()
23
+ .from(entries)
24
+ .where(eq(entries.id, id))
25
+ .limit(1);
26
+ if (row?.type !== MEDIA_ENTRY_TYPE)
27
+ throw notFound();
28
+ const isOwner = row.authorId === context.user.id;
29
+ if (!isOwner && !context.auth.can(capability)) {
30
+ throw errors.FORBIDDEN({ data: { capability } });
31
+ }
32
+ return row;
33
+ }
34
+ export function createMediaRouter(options) {
35
+ const acceptedTypeSet = new Set(options.acceptedTypes);
36
+ // Capability gating already keeps `entry:media:create` to the
37
+ // contributor+ tier (`registerEntryType` derives it). For at-scale
38
+ // deployments add a per-user quota or rate limit on this procedure —
39
+ // a single contributor can otherwise mint as many presigned URLs as
40
+ // they like and fill the bucket. Tracked as follow-up work; the
41
+ // server-side draft GC + KV-backed counter belong here.
42
+ const createUploadUrl = base
43
+ .use(authenticated)
44
+ .input(v.object({
45
+ filename: v.pipe(v.string(), v.minLength(1), v.maxLength(255)),
46
+ contentType: v.pipe(v.string(), v.minLength(1), v.maxLength(255), v.regex(CONTENT_TYPE_RE)),
47
+ size: v.pipe(v.number(), v.integer(), v.minValue(0)),
48
+ }))
49
+ .handler(async ({ input, context, errors }) => {
50
+ if (!context.auth.can("entry:media:create")) {
51
+ throw errors.FORBIDDEN({
52
+ data: { capability: "entry:media:create" },
53
+ });
54
+ }
55
+ if (input.size > options.maxUploadSize) {
56
+ throw errors.PAYLOAD_TOO_LARGE({
57
+ data: { limit: options.maxUploadSize, received: input.size },
58
+ });
59
+ }
60
+ // Normalize once at the entry point: strip parameters
61
+ // (`; charset=utf-8`), lowercase. We compare against the
62
+ // bare-mime allowlist; the worker-route's content-type check
63
+ // also splits on `;`, so storing the bare form keeps both
64
+ // sides in agreement.
65
+ const normalizedMime = normalizeMime(input.contentType);
66
+ if (!acceptedTypeSet.has(normalizedMime)) {
67
+ throw errors.UNSUPPORTED_MEDIA_TYPE({
68
+ data: { mime: normalizedMime },
69
+ });
70
+ }
71
+ const storage = context.storage;
72
+ if (!storage) {
73
+ throw errors.CONFLICT({ data: { reason: "storage_not_configured" } });
74
+ }
75
+ const id = crypto.randomUUID();
76
+ const ext = sanitizeExtension(input.filename) ?? extensionForMime(normalizedMime);
77
+ const datePrefix = new Date()
78
+ .toISOString()
79
+ .slice(0, 7)
80
+ .replace("-", "/");
81
+ const storageKey = ext === undefined
82
+ ? `${datePrefix}/${id}`
83
+ : `${datePrefix}/${id}.${ext}`;
84
+ const [created] = await context.db
85
+ .insert(entries)
86
+ .values({
87
+ type: MEDIA_ENTRY_TYPE,
88
+ title: input.filename,
89
+ slug: id,
90
+ status: "draft",
91
+ authorId: context.user.id,
92
+ meta: {
93
+ mime: normalizedMime,
94
+ size: input.size,
95
+ originalName: input.filename,
96
+ storageKey,
97
+ },
98
+ })
99
+ .returning();
100
+ if (!created) {
101
+ throw errors.CONFLICT({ data: { reason: "db_insert_failed" } });
102
+ }
103
+ if (storage.presignPut) {
104
+ let presigned;
105
+ try {
106
+ presigned = await storage.presignPut(storageKey, {
107
+ contentType: normalizedMime,
108
+ maxBytes: input.size,
109
+ // 60s is plenty for a same-page XHR PUT and tightens the
110
+ // replay window if the URL leaks (logs, browser history).
111
+ expiresIn: 60,
112
+ });
113
+ }
114
+ catch (error) {
115
+ await context.db.delete(entries).where(eq(entries.id, created.id));
116
+ throw error;
117
+ }
118
+ return {
119
+ uploadUrl: presigned.url,
120
+ method: presigned.method,
121
+ headers: presigned.headers,
122
+ mediaId: created.id,
123
+ storageKey,
124
+ expiresAt: presigned.expiresAt,
125
+ };
126
+ }
127
+ // Worker-routed fallback — when the runtime has the binding
128
+ // but no S3 credentials. Bytes flow through `env.MEDIA.put()`
129
+ // and the upload-route enforces the size cap on the actual
130
+ // stream, not just the Content-Length header.
131
+ return {
132
+ uploadUrl: withBasePath(`/_plumix/media/upload/${String(created.id)}`, context.basePath),
133
+ method: "PUT",
134
+ headers: { "content-type": normalizedMime },
135
+ mediaId: created.id,
136
+ storageKey,
137
+ expiresAt: Math.floor(Date.now() / 1000) + 60,
138
+ };
139
+ });
140
+ const confirm = base
141
+ .use(authenticated)
142
+ .input(v.object({ id: v.pipe(v.number(), v.integer(), v.minValue(1)) }))
143
+ .handler(async ({ input, context, errors }) => {
144
+ const notFound = () => errors.NOT_FOUND({ data: { kind: "media", id: input.id } });
145
+ const [row] = await context.db
146
+ .select()
147
+ .from(entries)
148
+ .where(eq(entries.id, input.id))
149
+ .limit(1);
150
+ if (row?.type !== MEDIA_ENTRY_TYPE)
151
+ throw notFound();
152
+ // Confirm is owner-only — `entry:media:edit_any` is for editing
153
+ // existing assets, not finalizing someone else's drafts. A
154
+ // non-owner shouldn't be able to publish another user's
155
+ // half-completed upload.
156
+ if (row.authorId !== context.user.id) {
157
+ throw errors.FORBIDDEN({
158
+ data: { capability: "entry:media:create" },
159
+ });
160
+ }
161
+ const meta = parseMediaMeta(row.meta);
162
+ if (!meta) {
163
+ throw errors.CONFLICT({ data: { reason: "media_meta_invalid" } });
164
+ }
165
+ const storage = context.storage;
166
+ if (!storage) {
167
+ throw errors.CONFLICT({ data: { reason: "storage_not_configured" } });
168
+ }
169
+ // Size check — bytes are not signed into the SigV4 query, so
170
+ // `meta.size` is just the client's claim at draft creation. Use
171
+ // head() to verify the actually-stored bytes don't exceed it.
172
+ const head = await storage.head(meta.storageKey);
173
+ if (!head) {
174
+ throw errors.CONFLICT({ data: { reason: "object_not_found" } });
175
+ }
176
+ if (head.size > meta.size) {
177
+ await storage.delete(meta.storageKey);
178
+ throw errors.PAYLOAD_TOO_LARGE({
179
+ data: { limit: meta.size, received: head.size },
180
+ });
181
+ }
182
+ // Verify the bytes match the claimed mime via magic-byte sniff.
183
+ // Delete on mismatch so an attacker can't force-leave junk in
184
+ // the bucket between a forged claim and our detection. The same
185
+ // sample feeds the dimension probe below, so read the larger of
186
+ // the two windows in one range request.
187
+ const sampleObj = await storage.get(meta.storageKey, {
188
+ range: {
189
+ offset: 0,
190
+ length: Math.max(MAGIC_BYTE_SAMPLE_SIZE, DIMENSION_SAMPLE_SIZE),
191
+ },
192
+ });
193
+ if (!sampleObj) {
194
+ throw errors.CONFLICT({ data: { reason: "object_not_found" } });
195
+ }
196
+ const sample = new Uint8Array(await sampleObj.arrayBuffer());
197
+ if (!looksLikeMime(sample, meta.mime)) {
198
+ await storage.delete(meta.storageKey);
199
+ throw errors.CONFLICT({ data: { reason: "mime_mismatch" } });
200
+ }
201
+ // Best-effort intrinsic dimensions — null for vector/unknown formats
202
+ // or a header we couldn't parse. Never blocks the confirm.
203
+ const dimensions = readImageDimensions(sample, meta.mime);
204
+ const dims = {
205
+ width: dimensions?.width ?? null,
206
+ height: dimensions?.height ?? null,
207
+ };
208
+ // CAS the draft → published transition: the WHERE clause
209
+ // includes `status = 'draft'`. Two concurrent confirms on the
210
+ // same draft will race the sniff but exactly one will flip the
211
+ // row; the loser sees `already_confirmed` instead of double-
212
+ // publishing or stomping `publishedAt`.
213
+ const [published] = await context.db
214
+ .update(entries)
215
+ .set({
216
+ status: "published",
217
+ publishedAt: new Date(),
218
+ meta: { ...meta, ...dims },
219
+ })
220
+ .where(and(eq(entries.id, input.id), eq(entries.status, "draft")))
221
+ .returning();
222
+ if (!published) {
223
+ throw errors.CONFLICT({ data: { reason: "already_confirmed" } });
224
+ }
225
+ const url = await resolveMediaUrl(storage, meta.storageKey, published.id, context.basePath);
226
+ return {
227
+ id: published.id,
228
+ url,
229
+ thumbnailUrl: thumbnailFor(context, url, meta.mime),
230
+ storageKey: meta.storageKey,
231
+ mime: meta.mime,
232
+ size: meta.size,
233
+ ...dims,
234
+ };
235
+ });
236
+ const list = base
237
+ .use(authenticated)
238
+ .input(mediaListInputSchema)
239
+ .handler(async ({ input, context, errors }) => {
240
+ try {
241
+ return await listMedia(context, input);
242
+ }
243
+ catch (error) {
244
+ throw mapMediaReadError(error, errors);
245
+ }
246
+ });
247
+ const update = base
248
+ .use(authenticated)
249
+ .input(v.object({
250
+ id: v.pipe(v.number(), v.integer(), v.minValue(1)),
251
+ title: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(255))),
252
+ alt: v.optional(v.pipe(v.string(), v.maxLength(1024))),
253
+ }))
254
+ .handler(async ({ input, context, errors }) => {
255
+ const notFound = () => errors.NOT_FOUND({ data: { kind: "media", id: input.id } });
256
+ const row = await loadOwnedMediaRow(context, input.id, {
257
+ capability: "entry:media:edit_any",
258
+ errors,
259
+ });
260
+ const meta = parseMediaMeta(row.meta);
261
+ if (!meta) {
262
+ throw errors.CONFLICT({ data: { reason: "media_meta_invalid" } });
263
+ }
264
+ const nextMeta = {
265
+ ...meta,
266
+ alt: input.alt ?? meta.alt,
267
+ };
268
+ const [updated] = await context.db
269
+ .update(entries)
270
+ .set({
271
+ title: input.title ?? row.title,
272
+ meta: nextMeta,
273
+ })
274
+ .where(eq(entries.id, input.id))
275
+ .returning();
276
+ if (!updated)
277
+ throw notFound();
278
+ return {
279
+ id: updated.id,
280
+ title: updated.title,
281
+ alt: nextMeta.alt,
282
+ };
283
+ });
284
+ const remove = base
285
+ .use(authenticated)
286
+ .input(v.object({ id: v.pipe(v.number(), v.integer(), v.minValue(1)) }))
287
+ .handler(async ({ input, context, errors }) => {
288
+ const notFound = () => errors.NOT_FOUND({ data: { kind: "media", id: input.id } });
289
+ const row = await loadOwnedMediaRow(context, input.id, {
290
+ capability: "entry:media:delete",
291
+ errors,
292
+ });
293
+ // Delete the row first, then the bytes. If the storage delete
294
+ // fails we'd rather leave an orphan in the bucket (admin can
295
+ // sweep) than a row pointing at a deleted file (which would
296
+ // surface as a permanent broken card). DB delete is the
297
+ // authoritative state.
298
+ const [deleted] = await context.db
299
+ .delete(entries)
300
+ .where(eq(entries.id, input.id))
301
+ .returning();
302
+ if (!deleted)
303
+ throw notFound();
304
+ const meta = parseMediaMeta(row.meta);
305
+ if (meta && context.storage) {
306
+ try {
307
+ await context.storage.delete(meta.storageKey);
308
+ }
309
+ catch (error) {
310
+ context.logger.warn("media_delete_storage_failed", {
311
+ error,
312
+ id: row.id,
313
+ storageKey: meta.storageKey,
314
+ });
315
+ }
316
+ }
317
+ return { id: deleted.id };
318
+ });
319
+ return { createUploadUrl, confirm, list, update, delete: remove };
320
+ }
321
+ // Map a media-read domain error to the oRPC typed error to throw; non-domain
322
+ // errors pass through for the caller to rethrow.
323
+ function mapMediaReadError(error, errors) {
324
+ if (!(error instanceof MediaReadError))
325
+ return error;
326
+ switch (error.data.code) {
327
+ case "forbidden":
328
+ return errors.FORBIDDEN({ data: { capability: error.data.capability } });
329
+ case "not_found":
330
+ return errors.NOT_FOUND({ data: { kind: "media", id: error.data.id } });
331
+ }
332
+ }
333
+ function normalizeMime(raw) {
334
+ const semi = raw.indexOf(";");
335
+ return (semi === -1 ? raw : raw.slice(0, semi)).trim().toLowerCase();
336
+ }
337
+ const SAFE_EXT_RE = /^[a-z0-9]+$/;
338
+ const MAX_EXT_LEN = 10;
339
+ function sanitizeExtension(filename) {
340
+ const lastDot = filename.lastIndexOf(".");
341
+ // `lastDot === 0` means the name starts with a dot (`.bashrc`) — that's
342
+ // a dotfile, not a `name.ext` shape. Treat as no extension.
343
+ if (lastDot <= 0 || lastDot === filename.length - 1)
344
+ return undefined;
345
+ const ext = filename.slice(lastDot + 1).toLowerCase();
346
+ if (ext.length > MAX_EXT_LEN)
347
+ return undefined;
348
+ if (!SAFE_EXT_RE.test(ext))
349
+ return undefined;
350
+ return ext;
351
+ }
352
+ //# sourceMappingURL=rpc.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rpc.js","sourceRoot":"","sources":["../src/rpc.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ import type { AppContext } from "plumix/plugin";
2
+ /**
3
+ * Worker-proxied media serve. Mounted at `GET /_plumix/media/serve/<id>`
4
+ * via `ctx.registerRoute({ path: "/serve/*", auth: "public" })`.
5
+ *
6
+ * Public on purpose — published media is meant to be embeddable in
7
+ * pages/posts. Three guards:
8
+ *
9
+ * 1. **Published-only**: looks up `entries` by id and requires
10
+ * `type='media' AND status='published'`. Drafts and trashed rows
11
+ * return 404.
12
+ * 2. **Mime sandboxing**: `X-Content-Type-Options: nosniff` always;
13
+ * `Content-Disposition: attachment` for any mime not in the
14
+ * inline-safe allowlist (forces download instead of render).
15
+ * 3. **Etag round-trip**: 304 on `If-None-Match` match so the worker
16
+ * isn't re-streaming bytes for every page view.
17
+ */
18
+ export declare function handleMediaServe(request: Request, ctx: AppContext): Promise<Response>;
19
+ //# sourceMappingURL=serve-route.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve-route.d.ts","sourceRoot":"","sources":["../src/serve-route.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AA0BhD;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE,UAAU,GACd,OAAO,CAAC,QAAQ,CAAC,CA6DnB"}
@@ -0,0 +1,122 @@
1
+ import { and, entries, eq } from "plumix/plugin";
2
+ import { parseMediaMeta } from "./meta.js";
3
+ const PREFIX = "/_plumix/media/serve/";
4
+ const MEDIA_ENTRY_TYPE = "media";
5
+ // Mimes safe to render inline same-origin. Everything else gets
6
+ // `Content-Disposition: attachment` to force download — defense
7
+ // against stored-XSS via uploaded content (text/html in a `.txt`,
8
+ // scripted SVG, polyglot bytes).
9
+ const INLINE_SAFE_MIMES = new Set([
10
+ "image/jpeg",
11
+ "image/png",
12
+ "image/gif",
13
+ "image/webp",
14
+ "image/avif",
15
+ "audio/mpeg",
16
+ "audio/wav",
17
+ "audio/ogg",
18
+ "video/mp4",
19
+ "video/webm",
20
+ "video/quicktime",
21
+ ]);
22
+ /**
23
+ * Worker-proxied media serve. Mounted at `GET /_plumix/media/serve/<id>`
24
+ * via `ctx.registerRoute({ path: "/serve/*", auth: "public" })`.
25
+ *
26
+ * Public on purpose — published media is meant to be embeddable in
27
+ * pages/posts. Three guards:
28
+ *
29
+ * 1. **Published-only**: looks up `entries` by id and requires
30
+ * `type='media' AND status='published'`. Drafts and trashed rows
31
+ * return 404.
32
+ * 2. **Mime sandboxing**: `X-Content-Type-Options: nosniff` always;
33
+ * `Content-Disposition: attachment` for any mime not in the
34
+ * inline-safe allowlist (forces download instead of render).
35
+ * 3. **Etag round-trip**: 304 on `If-None-Match` match so the worker
36
+ * isn't re-streaming bytes for every page view.
37
+ */
38
+ export async function handleMediaServe(request, ctx) {
39
+ const url = new URL(request.url);
40
+ if (!url.pathname.startsWith(PREFIX)) {
41
+ return new Response(null, { status: 404 });
42
+ }
43
+ const idStr = url.pathname.slice(PREFIX.length);
44
+ // 15 digits max keeps the parsed value below Number.MAX_SAFE_INTEGER
45
+ // (~9.007e15) — 16 digits could round on parseInt.
46
+ if (!/^[1-9]\d{0,14}$/.test(idStr)) {
47
+ return new Response(null, { status: 400 });
48
+ }
49
+ const id = Number.parseInt(idStr, 10);
50
+ const [row] = await ctx.db
51
+ .select()
52
+ .from(entries)
53
+ .where(and(eq(entries.id, id), eq(entries.type, MEDIA_ENTRY_TYPE), eq(entries.status, "published")))
54
+ .limit(1);
55
+ if (!row)
56
+ return new Response(null, { status: 404 });
57
+ const meta = parseMediaMeta(row.meta);
58
+ if (!meta)
59
+ return new Response(null, { status: 404 });
60
+ const storage = ctx.storage;
61
+ if (!storage)
62
+ return new Response(null, { status: 503 });
63
+ const obj = await storage.get(meta.storageKey);
64
+ if (!obj)
65
+ return new Response(null, { status: 404 });
66
+ const ifNoneMatch = request.headers.get("if-none-match");
67
+ if (ifNoneMatch && obj.etag && weakEtagMatch(ifNoneMatch, obj.etag)) {
68
+ return new Response(null, {
69
+ status: 304,
70
+ headers: { etag: obj.etag, "cache-control": cacheControl() },
71
+ });
72
+ }
73
+ // `?attachment=1` forces a download regardless of mime. The admin's
74
+ // Download button uses this so the response carries the attachment
75
+ // disposition even when `publicUrlBase` would otherwise route around
76
+ // the worker (HTML `<a download>` is silently ignored cross-origin
77
+ // without a matching `Content-Disposition` header).
78
+ const forceAttachment = url.searchParams.get("attachment") === "1";
79
+ const inline = !forceAttachment && INLINE_SAFE_MIMES.has(meta.mime);
80
+ const headers = new Headers();
81
+ headers.set("content-type", meta.mime);
82
+ headers.set("content-length", String(obj.size));
83
+ headers.set("cache-control", cacheControl());
84
+ headers.set("x-content-type-options", "nosniff");
85
+ if (obj.etag)
86
+ headers.set("etag", obj.etag);
87
+ if (!inline)
88
+ headers.set("content-disposition", contentDisposition(row.title));
89
+ return new Response(obj.body, { status: 200, headers });
90
+ }
91
+ function contentDisposition(title) {
92
+ // RFC 6266: ASCII fallback in `filename="..."` plus a UTF-8
93
+ // `filename*=UTF-8''...` so non-Latin titles aren't mangled.
94
+ const ascii = sanitizeFilename(title);
95
+ const utf8 = encodeURIComponent(title);
96
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${utf8}`;
97
+ }
98
+ function cacheControl() {
99
+ // Short cache + revalidate. Long max-age + key reuse (alt updates
100
+ // don't change bytes; future replace-file flows might) leaves
101
+ // intermediaries serving stale bytes for hours.
102
+ return "public, max-age=60, must-revalidate";
103
+ }
104
+ function weakEtagMatch(ifNoneMatch, etag) {
105
+ // `If-None-Match` accepts comma-separated tags; either side may be
106
+ // weak (`W/"..."`). Normalize and check membership.
107
+ const normalize = (s) => s.replace(/^W\//, "").trim();
108
+ const target = normalize(etag);
109
+ return ifNoneMatch
110
+ .split(",")
111
+ .map((t) => normalize(t))
112
+ .some((t) => t === target || t === "*");
113
+ }
114
+ const SAFE_FILENAME_RE = /[^a-zA-Z0-9._-]/g;
115
+ function sanitizeFilename(title) {
116
+ // Strip anything that could break the Content-Disposition quoting
117
+ // or attempt directory traversal in the suggested name. ASCII-safe
118
+ // subset; non-ASCII titles fall back to a stable placeholder.
119
+ const cleaned = title.replace(SAFE_FILENAME_RE, "_").slice(0, 100);
120
+ return cleaned.length > 0 ? cleaned : "download";
121
+ }
122
+ //# sourceMappingURL=serve-route.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve-route.js","sourceRoot":"","sources":["../src/serve-route.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ import type { AppContext } from "plumix/plugin";
2
+ /**
3
+ * Worker-routed upload handler. Mounted at `PUT /_plumix/media/upload/<id>`
4
+ * via `ctx.registerRoute({ path: "/upload/*", auth: "authenticated" })`.
5
+ *
6
+ * Defends against:
7
+ * - **Unbounded body**: `meta.size` (signed at draft creation) caps the
8
+ * actual byte stream, not just the Content-Length header. Chunked
9
+ * uploads or a lying header trip the counting transform and abort.
10
+ * - **Path confusion**: only `/upload/<digits>` matches; trailing
11
+ * segments or non-numeric ids are rejected before any DB hit.
12
+ *
13
+ * CSRF is enforced at the `/_plumix/*` dispatcher boundary (X-Plumix-
14
+ * Request header + Origin check); we don't re-check here.
15
+ */
16
+ export declare function handleWorkerUpload(request: Request, ctx: AppContext): Promise<Response>;
17
+ //# sourceMappingURL=upload-route.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upload-route.d.ts","sourceRoot":"","sources":["../src/upload-route.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAYhD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE,UAAU,GACd,OAAO,CAAC,QAAQ,CAAC,CAiFnB"}
@@ -0,0 +1,106 @@
1
+ import { and, entries, eq } from "plumix/plugin";
2
+ import { parseMediaMeta } from "./meta.js";
3
+ const MEDIA_ENTRY_TYPE = "media";
4
+ // Anchored: digits only, no leading zeros, ≤16 chars (well above any
5
+ // realistic SQLite int). Rejects scientific notation, signs, padding
6
+ // whitespace, and unicode digits.
7
+ const ID_RE = /^[1-9]\d{0,15}$/;
8
+ /**
9
+ * Worker-routed upload handler. Mounted at `PUT /_plumix/media/upload/<id>`
10
+ * via `ctx.registerRoute({ path: "/upload/*", auth: "authenticated" })`.
11
+ *
12
+ * Defends against:
13
+ * - **Unbounded body**: `meta.size` (signed at draft creation) caps the
14
+ * actual byte stream, not just the Content-Length header. Chunked
15
+ * uploads or a lying header trip the counting transform and abort.
16
+ * - **Path confusion**: only `/upload/<digits>` matches; trailing
17
+ * segments or non-numeric ids are rejected before any DB hit.
18
+ *
19
+ * CSRF is enforced at the `/_plumix/*` dispatcher boundary (X-Plumix-
20
+ * Request header + Origin check); we don't re-check here.
21
+ */
22
+ export async function handleWorkerUpload(request, ctx) {
23
+ const user = ctx.user;
24
+ if (!user)
25
+ return jsonError(401, "unauthorized");
26
+ const url = new URL(request.url);
27
+ const idStr = url.pathname.slice(url.pathname.lastIndexOf("/") + 1);
28
+ if (!ID_RE.test(idStr))
29
+ return jsonError(400, "invalid_id");
30
+ const id = Number.parseInt(idStr, 10);
31
+ // Reject `/upload/<id>/<extra>` — the route is mounted via "/upload/*"
32
+ // wildcard, so we have to enforce shape here.
33
+ if (url.pathname !== `/_plumix/media/upload/${idStr}`) {
34
+ return jsonError(400, "invalid_path");
35
+ }
36
+ const storage = ctx.storage;
37
+ if (!storage)
38
+ return jsonError(503, "storage_not_configured");
39
+ const [row] = await ctx.db
40
+ .select()
41
+ .from(entries)
42
+ .where(and(eq(entries.id, id), eq(entries.type, MEDIA_ENTRY_TYPE)))
43
+ .limit(1);
44
+ if (!row)
45
+ return jsonError(404, "not_found");
46
+ if (row.status !== "draft")
47
+ return jsonError(409, "not_a_draft");
48
+ const isOwner = row.authorId === user.id;
49
+ if (!isOwner && !ctx.auth.can("entry:media:edit_any")) {
50
+ return jsonError(403, "forbidden");
51
+ }
52
+ const meta = parseMediaMeta(row.meta);
53
+ if (!meta)
54
+ return jsonError(409, "media_meta_invalid");
55
+ const declared = request.headers.get("content-type") ?? "";
56
+ const declaredBase = declared.split(";")[0]?.trim().toLowerCase();
57
+ if (declaredBase !== meta.mime.toLowerCase()) {
58
+ return jsonError(415, "content_type_mismatch");
59
+ }
60
+ // Reject missing CL (chunked is unbounded) or CL > meta.size; HTTP
61
+ // framing truncates the actual stream to the declared length.
62
+ const declaredLengthHeader = request.headers.get("content-length");
63
+ if (declaredLengthHeader === null) {
64
+ return jsonError(411, "content_length_required");
65
+ }
66
+ const declaredLength = Number(declaredLengthHeader);
67
+ if (!Number.isInteger(declaredLength) ||
68
+ declaredLength < 0 ||
69
+ declaredLength > meta.size) {
70
+ return jsonError(413, "payload_too_large");
71
+ }
72
+ const body = request.body;
73
+ if (!body)
74
+ return jsonError(400, "empty_body");
75
+ // Stream the body straight to R2 per the docs example:
76
+ // await env.R2.put(key, request.body, { httpMetadata })
77
+ try {
78
+ await storage.put(meta.storageKey, body, { contentType: meta.mime });
79
+ }
80
+ catch (error) {
81
+ // Best-effort cleanup — a partial put can leave junk behind.
82
+ try {
83
+ await storage.delete(meta.storageKey);
84
+ }
85
+ catch (cleanupError) {
86
+ ctx.logger.warn("media_worker_upload_cleanup_failed", {
87
+ error: cleanupError,
88
+ key: meta.storageKey,
89
+ });
90
+ }
91
+ ctx.logger.warn("media_worker_upload_failed", {
92
+ error,
93
+ id,
94
+ storageKey: meta.storageKey,
95
+ });
96
+ return jsonError(502, "storage_put_failed");
97
+ }
98
+ return new Response(null, { status: 204 });
99
+ }
100
+ function jsonError(status, reason) {
101
+ return new Response(JSON.stringify({ error: reason }), {
102
+ status,
103
+ headers: { "content-type": "application/json" },
104
+ });
105
+ }
106
+ //# sourceMappingURL=upload-route.js.map