@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.
- package/LICENSE +21 -0
- package/README.md +211 -0
- package/dist/admin/FocalPointField.d.ts +22 -0
- package/dist/admin/FocalPointField.d.ts.map +1 -0
- package/dist/admin/FocalPointField.js +71 -0
- package/dist/admin/FocalPointField.js.map +1 -0
- package/dist/admin/MediaLibrary.d.ts +43 -0
- package/dist/admin/MediaLibrary.d.ts.map +1 -0
- package/dist/admin/MediaLibrary.js +698 -0
- package/dist/admin/MediaLibrary.js.map +1 -0
- package/dist/admin/MediaListPickerField.d.ts +14 -0
- package/dist/admin/MediaListPickerField.d.ts.map +1 -0
- package/dist/admin/MediaListPickerField.js +161 -0
- package/dist/admin/MediaListPickerField.js.map +1 -0
- package/dist/admin/MediaPickerField.d.ts +25 -0
- package/dist/admin/MediaPickerField.d.ts.map +1 -0
- package/dist/admin/MediaPickerField.js +82 -0
- package/dist/admin/MediaPickerField.js.map +1 -0
- package/dist/admin/MediaUrlField.d.ts +14 -0
- package/dist/admin/MediaUrlField.d.ts.map +1 -0
- package/dist/admin/MediaUrlField.js +29 -0
- package/dist/admin/MediaUrlField.js.map +1 -0
- package/dist/admin/index.d.ts +11 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +49 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/messages.d.ts +24 -0
- package/dist/admin/messages.d.ts.map +1 -0
- package/dist/admin/messages.js +28 -0
- package/dist/admin/messages.js.map +1 -0
- package/dist/blocks/file/index.d.ts +2 -0
- package/dist/blocks/file/index.d.ts.map +1 -0
- package/dist/blocks/file/index.js +109 -0
- package/dist/blocks/file/index.js.map +1 -0
- package/dist/blocks/file/normalize.d.ts +10 -0
- package/dist/blocks/file/normalize.d.ts.map +1 -0
- package/dist/blocks/file/normalize.js +45 -0
- package/dist/blocks/file/normalize.js.map +1 -0
- package/dist/blocks/image/index.d.ts +2 -0
- package/dist/blocks/image/index.d.ts.map +1 -0
- package/dist/blocks/image/index.js +141 -0
- package/dist/blocks/image/index.js.map +1 -0
- package/dist/blocks/image/normalize.d.ts +7 -0
- package/dist/blocks/image/normalize.d.ts.map +1 -0
- package/dist/blocks/image/normalize.js +20 -0
- package/dist/blocks/image/normalize.js.map +1 -0
- package/dist/blocks.d.ts +2 -0
- package/dist/blocks.d.ts.map +1 -0
- package/dist/blocks.js +6 -0
- package/dist/blocks.js.map +1 -0
- package/dist/builder.d.ts +80 -0
- package/dist/builder.d.ts.map +1 -0
- package/dist/builder.js +61 -0
- package/dist/builder.js.map +1 -0
- package/dist/fields.d.ts +3 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +7 -0
- package/dist/fields.js.map +1 -0
- package/dist/image-dimensions.d.ts +7 -0
- package/dist/image-dimensions.d.ts.map +1 -0
- package/dist/image-dimensions.js +142 -0
- package/dist/image-dimensions.js.map +1 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +216 -0
- package/dist/index.js.map +1 -0
- package/dist/lookup.d.ts +43 -0
- package/dist/lookup.d.ts.map +1 -0
- package/dist/lookup.js +154 -0
- package/dist/lookup.js.map +1 -0
- package/dist/magic-bytes.d.ts +4 -0
- package/dist/magic-bytes.d.ts.map +1 -0
- package/dist/magic-bytes.js +107 -0
- package/dist/magic-bytes.js.map +1 -0
- package/dist/mcp-tools.d.ts +10 -0
- package/dist/mcp-tools.d.ts.map +1 -0
- package/dist/mcp-tools.js +43 -0
- package/dist/mcp-tools.js.map +1 -0
- package/dist/media-blocks.d.ts +3 -0
- package/dist/media-blocks.d.ts.map +1 -0
- package/dist/media-blocks.js +10 -0
- package/dist/media-blocks.js.map +1 -0
- package/dist/meta.d.ts +12 -0
- package/dist/meta.d.ts.map +1 -0
- package/dist/meta.js +20 -0
- package/dist/meta.js.map +1 -0
- package/dist/mime.d.ts +3 -0
- package/dist/mime.d.ts.map +1 -0
- package/dist/mime.js +58 -0
- package/dist/mime.js.map +1 -0
- package/dist/read-service.d.ts +66 -0
- package/dist/read-service.d.ts.map +1 -0
- package/dist/read-service.js +156 -0
- package/dist/read-service.js.map +1 -0
- package/dist/rpc.d.ts +7 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +352 -0
- package/dist/rpc.js.map +1 -0
- package/dist/serve-route.d.ts +19 -0
- package/dist/serve-route.d.ts.map +1 -0
- package/dist/serve-route.js +122 -0
- package/dist/serve-route.js.map +1 -0
- package/dist/upload-route.d.ts +17 -0
- package/dist/upload-route.d.ts.map +1 -0
- package/dist/upload-route.js +106 -0
- package/dist/upload-route.js.map +1 -0
- 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
|
package/dist/rpc.js.map
ADDED
|
@@ -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
|