emdash 0.10.0 → 0.11.1
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/{apply-UsrFuO7l.mjs → apply-Ded_1vng.mjs} +36 -25
- package/dist/{apply-UsrFuO7l.mjs.map → apply-Ded_1vng.mjs.map} +1 -1
- package/dist/astro/index.d.mts +5 -5
- package/dist/astro/index.mjs +1 -1
- package/dist/astro/middleware/auth.d.mts +5 -5
- package/dist/astro/middleware/redirect.mjs +2 -2
- package/dist/astro/middleware.d.mts.map +1 -1
- package/dist/astro/middleware.mjs +83 -33
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +10 -7
- package/dist/astro/types.d.mts.map +1 -1
- package/dist/{byline-C3vnhIpU.mjs → byline-gFn1r0vA.mjs} +2 -2
- package/dist/{byline-C3vnhIpU.mjs.map → byline-gFn1r0vA.mjs.map} +1 -1
- package/dist/{bylines-esI7ioa9.mjs → bylines-DTFI8nDM.mjs} +4 -4
- package/dist/{bylines-esI7ioa9.mjs.map → bylines-DTFI8nDM.mjs.map} +1 -1
- package/dist/{cache-fTzxgMFJ.mjs → cache-BAJbeoZ8.mjs} +2 -2
- package/dist/{cache-fTzxgMFJ.mjs.map → cache-BAJbeoZ8.mjs.map} +1 -1
- package/dist/{chunks-Da2-b-oA.mjs → chunks-BK1oZS-l.mjs} +2 -2
- package/dist/{chunks-Da2-b-oA.mjs.map → chunks-BK1oZS-l.mjs.map} +1 -1
- package/dist/cli/index.mjs +102 -27
- package/dist/cli/index.mjs.map +1 -1
- package/dist/{content-C7G4QXkK.mjs → content-CERxPUN0.mjs} +2 -2
- package/dist/{content-C7G4QXkK.mjs.map → content-CERxPUN0.mjs.map} +1 -1
- package/dist/database/instrumentation.d.mts +6 -4
- package/dist/database/instrumentation.d.mts.map +1 -1
- package/dist/database/instrumentation.mjs +19 -7
- package/dist/database/instrumentation.mjs.map +1 -1
- package/dist/db/index.d.mts +2 -2
- package/dist/db/index.mjs +1 -1
- package/dist/{index-DjPMOfO0.d.mts → index-BogfvE-z.d.mts} +32 -24
- package/dist/index-BogfvE-z.d.mts.map +1 -0
- package/dist/index.d.mts +7 -7
- package/dist/index.mjs +19 -19
- package/dist/{load-sXRuM7Us.mjs → load-DR1VwFXR.mjs} +2 -2
- package/dist/{load-sXRuM7Us.mjs.map → load-DR1VwFXR.mjs.map} +1 -1
- package/dist/{loader-Bx2_9-5e.mjs → loader-ou_PXAjg.mjs} +2 -2
- package/dist/{loader-Bx2_9-5e.mjs.map → loader-ou_PXAjg.mjs.map} +1 -1
- package/dist/media/local-runtime.d.mts +5 -5
- package/dist/media/local-runtime.mjs +1 -1
- package/dist/{media-D8FbNsl0.mjs → media-1fFhub9c.mjs} +21 -9
- package/dist/media-1fFhub9c.mjs.map +1 -0
- package/dist/page/index.d.mts +2 -2
- package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
- package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
- package/dist/{query-Bo-msrmu.mjs → query-8c_meo_K.mjs} +10 -10
- package/dist/{query-Bo-msrmu.mjs.map → query-8c_meo_K.mjs.map} +1 -1
- package/dist/{registry-Beb7wxFc.mjs → registry-Do34mz_P.mjs} +6 -5
- package/dist/registry-Do34mz_P.mjs.map +1 -0
- package/dist/{request-cache-C-tIpYIw.mjs → request-cache-D4I69LeL.mjs} +6 -2
- package/dist/request-cache-D4I69LeL.mjs.map +1 -0
- package/dist/request-context.d.mts +27 -1
- package/dist/request-context.d.mts.map +1 -1
- package/dist/request-context.mjs +16 -3
- package/dist/request-context.mjs.map +1 -1
- package/dist/{runner-DMnlIkh4.mjs → runner-DIcU2UCC.mjs} +174 -152
- package/dist/runner-DIcU2UCC.mjs.map +1 -0
- package/dist/{runner-Clwe4Mme.d.mts → runner-Iu3IZSDM.d.mts} +2 -2
- package/dist/{runner-Clwe4Mme.d.mts.map → runner-Iu3IZSDM.d.mts.map} +1 -1
- package/dist/runtime.d.mts +5 -5
- package/dist/runtime.mjs +1 -1
- package/dist/{search-DkN-BqsS.mjs → search-DuWhx4NG.mjs} +172 -30
- package/dist/search-DuWhx4NG.mjs.map +1 -0
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +10 -10
- package/dist/{taxonomies-CTtewrSQ.mjs → taxonomies-Bw76xAxo.mjs} +6 -6
- package/dist/{taxonomies-CTtewrSQ.mjs.map → taxonomies-Bw76xAxo.mjs.map} +1 -1
- package/dist/{taxonomy-DSxx2K2L.mjs → taxonomy-D6NvlKo8.mjs} +3 -3
- package/dist/{taxonomy-DSxx2K2L.mjs.map → taxonomy-D6NvlKo8.mjs.map} +1 -1
- package/dist/{types-Eg829jj9.mjs → types-56BKbld_.mjs} +1 -1
- package/dist/types-56BKbld_.mjs.map +1 -0
- package/dist/{types-Dtx1mSMX.d.mts → types-BQx6ZXpR.d.mts} +2 -1
- package/dist/types-BQx6ZXpR.d.mts.map +1 -0
- package/dist/{types-Dl1fgFjn.d.mts → types-BTe41zL6.d.mts} +4 -3
- package/dist/types-BTe41zL6.d.mts.map +1 -0
- package/dist/types-DiI8NOG_.mjs +16 -0
- package/dist/types-DiI8NOG_.mjs.map +1 -0
- package/dist/{types-D19uBYWn.d.mts → types-IjUrQMVe.d.mts} +21 -245
- package/dist/types-IjUrQMVe.d.mts.map +1 -0
- package/dist/{validate-DHGwADqO.d.mts → validate-CcVQQpmH.d.mts} +7 -3
- package/dist/validate-CcVQQpmH.d.mts.map +1 -0
- package/dist/{validate-CBIbxM3L.mjs → validate-UK4Ja1uo.mjs} +3 -3
- package/dist/{validate-CBIbxM3L.mjs.map → validate-UK4Ja1uo.mjs.map} +1 -1
- package/dist/{validation-B1NYiEos.mjs → validation-Vc5DQkJa.mjs} +4 -4
- package/dist/{validation-B1NYiEos.mjs.map → validation-Vc5DQkJa.mjs.map} +1 -1
- package/dist/version-JjSqv90m.mjs +7 -0
- package/dist/{version-CMD42IRC.mjs.map → version-JjSqv90m.mjs.map} +1 -1
- package/dist/{zod-generator-BNJDQBSZ.mjs → zod-generator-CHnJUP2l.mjs} +1 -1
- package/dist/{zod-generator-BNJDQBSZ.mjs.map → zod-generator-CHnJUP2l.mjs.map} +1 -1
- package/package.json +9 -8
- package/src/api/errors.ts +5 -0
- package/src/api/handlers/content.ts +9 -0
- package/src/api/handlers/media-allowlist.ts +40 -0
- package/src/api/handlers/media.ts +1 -1
- package/src/api/handlers/menus.ts +158 -28
- package/src/api/handlers/validate-media-fields.ts +125 -0
- package/src/api/schemas/media.ts +23 -3
- package/src/api/schemas/schema.ts +11 -2
- package/src/astro/middleware.ts +46 -11
- package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/duplicate.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/publish.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/restore.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/schedule.ts +2 -2
- package/src/astro/routes/api/content/[collection]/[id]/unpublish.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id].ts +2 -2
- package/src/astro/routes/api/content/[collection]/index.ts +1 -1
- package/src/astro/routes/api/media/upload-url.ts +10 -4
- package/src/astro/routes/api/media.ts +12 -4
- package/src/astro/types.ts +5 -1
- package/src/auth/rate-limit.ts +3 -3
- package/src/cli/commands/bundle-utils.ts +81 -6
- package/src/cli/commands/bundle.ts +18 -15
- package/src/cli/commands/export-seed.ts +57 -3
- package/src/database/instrumentation.ts +22 -8
- package/src/database/migrations/016_api_tokens.ts +18 -3
- package/src/database/migrations/037_credential_algorithm.ts +18 -0
- package/src/database/migrations/runner.ts +2 -0
- package/src/database/repositories/media.ts +40 -10
- package/src/database/types.ts +2 -1
- package/src/emdash-runtime.ts +16 -3
- package/src/fields/file.ts +7 -6
- package/src/fields/image.ts +12 -11
- package/src/fields/types.ts +3 -0
- package/src/index.ts +1 -1
- package/src/mcp/server.ts +37 -8
- package/src/media/mime.ts +75 -0
- package/src/plugins/types.ts +81 -191
- package/src/request-cache.ts +6 -2
- package/src/request-context.ts +42 -2
- package/src/schema/registry.ts +5 -5
- package/src/schema/types.ts +3 -2
- package/src/seed/apply.ts +25 -8
- package/src/seed/types.ts +4 -0
- package/dist/index-DjPMOfO0.d.mts.map +0 -1
- package/dist/media-D8FbNsl0.mjs.map +0 -1
- package/dist/registry-Beb7wxFc.mjs.map +0 -1
- package/dist/request-cache-C-tIpYIw.mjs.map +0 -1
- package/dist/runner-DMnlIkh4.mjs.map +0 -1
- package/dist/search-DkN-BqsS.mjs.map +0 -1
- package/dist/types-CoO6mpV3.mjs +0 -68
- package/dist/types-CoO6mpV3.mjs.map +0 -1
- package/dist/types-D19uBYWn.d.mts.map +0 -1
- package/dist/types-Dl1fgFjn.d.mts.map +0 -1
- package/dist/types-Dtx1mSMX.d.mts.map +0 -1
- package/dist/types-Eg829jj9.mjs.map +0 -1
- package/dist/validate-DHGwADqO.d.mts.map +0 -1
- package/dist/version-CMD42IRC.mjs +0 -7
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Kysely } from "kysely";
|
|
2
|
+
|
|
3
|
+
import type { Database } from "../../database/types.js";
|
|
4
|
+
import { matchesMimeAllowlist, parseAllowedMimeTypes } from "../../media/mime.js";
|
|
5
|
+
import { requestCached } from "../../request-cache.js";
|
|
6
|
+
import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js";
|
|
7
|
+
import type { ApiResult } from "../types.js";
|
|
8
|
+
|
|
9
|
+
interface FieldRow {
|
|
10
|
+
slug: string;
|
|
11
|
+
type: string;
|
|
12
|
+
allowedMimeTypes: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface MediaRefValue {
|
|
16
|
+
id?: unknown;
|
|
17
|
+
provider?: unknown;
|
|
18
|
+
mimeType?: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function asMediaRef(value: unknown): MediaRefValue | null {
|
|
22
|
+
if (value === null || value === undefined) return null;
|
|
23
|
+
if (typeof value !== "object" || Array.isArray(value)) return null;
|
|
24
|
+
return value as MediaRefValue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fail(message: string): ApiResult<never> {
|
|
28
|
+
return { success: false, error: { code: "INVALID_MIME_FOR_FIELD", message } };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function loadMediaFieldsForCollection(
|
|
32
|
+
db: Kysely<Database>,
|
|
33
|
+
collectionSlug: string,
|
|
34
|
+
): Promise<FieldRow[]> {
|
|
35
|
+
const rows = await db
|
|
36
|
+
.selectFrom("_emdash_fields")
|
|
37
|
+
.innerJoin("_emdash_collections", "_emdash_collections.id", "_emdash_fields.collection_id")
|
|
38
|
+
.select(["_emdash_fields.slug", "_emdash_fields.type", "_emdash_fields.validation"])
|
|
39
|
+
.where("_emdash_collections.slug", "=", collectionSlug)
|
|
40
|
+
.where("_emdash_fields.type", "in", ["file", "image"])
|
|
41
|
+
.execute();
|
|
42
|
+
|
|
43
|
+
const out: FieldRow[] = [];
|
|
44
|
+
for (const row of rows) {
|
|
45
|
+
const list = parseAllowedMimeTypes(row.validation);
|
|
46
|
+
if (!list) continue;
|
|
47
|
+
out.push({ slug: row.slug, type: row.type, allowedMimeTypes: list });
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function validateMediaFields(
|
|
53
|
+
db: Kysely<Database>,
|
|
54
|
+
collectionSlug: string,
|
|
55
|
+
data: Record<string, unknown>,
|
|
56
|
+
): Promise<ApiResult<true>> {
|
|
57
|
+
// Cache is keyed on slug only. If a handler creates/modifies a field and
|
|
58
|
+
// then writes content in the same request (e.g. bulk import), the cached
|
|
59
|
+
// list will be stale for that request. This is an edge case in normal use.
|
|
60
|
+
const fields = await requestCached(`mediaFields:${collectionSlug}`, () =>
|
|
61
|
+
loadMediaFieldsForCollection(db, collectionSlug),
|
|
62
|
+
);
|
|
63
|
+
if (fields.length === 0) return { success: true, data: true };
|
|
64
|
+
|
|
65
|
+
// Collect local media ids that need a MIME lookup
|
|
66
|
+
const localIds = new Set<string>();
|
|
67
|
+
for (const field of fields) {
|
|
68
|
+
const ref = asMediaRef(data[field.slug]);
|
|
69
|
+
if (!ref) continue;
|
|
70
|
+
const provider = typeof ref.provider === "string" ? ref.provider : "local";
|
|
71
|
+
if (provider === "local" && typeof ref.id === "string") {
|
|
72
|
+
localIds.add(ref.id);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Batch-load local media MIMEs
|
|
77
|
+
const idList = [...localIds];
|
|
78
|
+
const mimeById = new Map<string, string>();
|
|
79
|
+
if (idList.length > 0) {
|
|
80
|
+
for (const batch of chunks(idList, SQL_BATCH_SIZE)) {
|
|
81
|
+
const rows = await db
|
|
82
|
+
.selectFrom("media")
|
|
83
|
+
.select(["id", "mime_type"])
|
|
84
|
+
.where("id", "in", batch)
|
|
85
|
+
.execute();
|
|
86
|
+
for (const r of rows) mimeById.set(r.id, r.mime_type);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const field of fields) {
|
|
91
|
+
const value = data[field.slug];
|
|
92
|
+
if (value === null || value === undefined) continue;
|
|
93
|
+
const ref = asMediaRef(value);
|
|
94
|
+
if (!ref) continue;
|
|
95
|
+
|
|
96
|
+
const provider = typeof ref.provider === "string" ? ref.provider : "local";
|
|
97
|
+
|
|
98
|
+
// External providers carry mimeType in the ref; trust it as-is.
|
|
99
|
+
// Local media: look up the stored mimeType by id.
|
|
100
|
+
let mime: string | undefined;
|
|
101
|
+
if (provider === "local") {
|
|
102
|
+
if (typeof ref.id !== "string") {
|
|
103
|
+
return fail(`Field '${field.slug}' references media with an invalid id`);
|
|
104
|
+
}
|
|
105
|
+
mime = mimeById.get(ref.id);
|
|
106
|
+
if (!mime) {
|
|
107
|
+
return fail(`Field '${field.slug}' references media with unknown MIME type`);
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
if (typeof ref.mimeType !== "string") {
|
|
111
|
+
return fail(`Field '${field.slug}' requires a mimeType declaration for non-local media`);
|
|
112
|
+
}
|
|
113
|
+
// TODO: long-term, consider a server-side HEAD probe or provider-vouched
|
|
114
|
+
// MIMEs for non-local refs; for now the constraint is only as strong as
|
|
115
|
+
// the client that constructed the ref.
|
|
116
|
+
mime = ref.mimeType;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!matchesMimeAllowlist(mime, field.allowedMimeTypes)) {
|
|
120
|
+
return fail(`Field '${field.slug}' does not accept ${mime}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { success: true, data: true };
|
|
125
|
+
}
|
package/src/api/schemas/media.ts
CHANGED
|
@@ -6,9 +6,21 @@ import { cursorPaginationQuery } from "./common.js";
|
|
|
6
6
|
// Media: Input schemas
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Accepts a comma-separated string (from URL query params) or an array of
|
|
11
|
+
* strings (from JSON body or programmatic use) and normalises to string[].
|
|
12
|
+
*/
|
|
13
|
+
const mimeTypeFilter = z
|
|
14
|
+
.union([z.string(), z.array(z.string())])
|
|
15
|
+
.transform((v) => {
|
|
16
|
+
const arr = Array.isArray(v) ? v : v.split(",");
|
|
17
|
+
return arr.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
18
|
+
})
|
|
19
|
+
.optional();
|
|
20
|
+
|
|
9
21
|
export const mediaListQuery = cursorPaginationQuery
|
|
10
22
|
.extend({
|
|
11
|
-
mimeType:
|
|
23
|
+
mimeType: mimeTypeFilter,
|
|
12
24
|
})
|
|
13
25
|
.meta({ id: "MediaListQuery" });
|
|
14
26
|
|
|
@@ -30,6 +42,10 @@ export function formatFileSize(bytes: number): string {
|
|
|
30
42
|
return `${Math.floor(bytes / 1024 / 1024)}MB`;
|
|
31
43
|
}
|
|
32
44
|
|
|
45
|
+
// Matches a full MIME type (type/subtype) with an optional semicolon-delimited
|
|
46
|
+
// parameter section. Forbids CR/LF to prevent header injection.
|
|
47
|
+
const CONTENT_TYPE_RE = /^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]+(\s*;[^\r\n]*)?$/i;
|
|
48
|
+
|
|
33
49
|
export function mediaUploadUrlBody(maxSize: number) {
|
|
34
50
|
if (!Number.isFinite(maxSize) || maxSize <= 0) {
|
|
35
51
|
throw new Error(`EmDash: maxUploadSize must be a positive finite number, got ${maxSize}`);
|
|
@@ -37,13 +53,17 @@ export function mediaUploadUrlBody(maxSize: number) {
|
|
|
37
53
|
return z
|
|
38
54
|
.object({
|
|
39
55
|
filename: z.string().min(1, "filename is required"),
|
|
40
|
-
contentType: z
|
|
56
|
+
contentType: z
|
|
57
|
+
.string()
|
|
58
|
+
.min(1, "contentType is required")
|
|
59
|
+
.regex(CONTENT_TYPE_RE, "Invalid content type"),
|
|
41
60
|
size: z
|
|
42
61
|
.number()
|
|
43
62
|
.int()
|
|
44
63
|
.positive()
|
|
45
64
|
.max(maxSize, `File size must not exceed ${formatFileSize(maxSize)}`),
|
|
46
65
|
contentHash: z.string().optional(),
|
|
66
|
+
fieldId: z.string().optional(),
|
|
47
67
|
})
|
|
48
68
|
.meta({ id: "MediaUploadUrlBody" });
|
|
49
69
|
}
|
|
@@ -59,7 +79,7 @@ export const mediaConfirmBody = z
|
|
|
59
79
|
export const mediaProviderListQuery = cursorPaginationQuery
|
|
60
80
|
.extend({
|
|
61
81
|
query: z.string().optional(),
|
|
62
|
-
mimeType:
|
|
82
|
+
mimeType: mimeTypeFilter,
|
|
63
83
|
})
|
|
64
84
|
.meta({ id: "MediaProviderListQuery" });
|
|
65
85
|
|
|
@@ -49,6 +49,15 @@ const fieldValidation = z
|
|
|
49
49
|
subFields: z.array(repeaterSubFieldSchema).min(1).optional(),
|
|
50
50
|
minItems: z.number().int().min(0).optional(),
|
|
51
51
|
maxItems: z.number().int().min(1).optional(),
|
|
52
|
+
allowedMimeTypes: z
|
|
53
|
+
.array(
|
|
54
|
+
z
|
|
55
|
+
.string()
|
|
56
|
+
.regex(/^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]*$/i, "Invalid MIME type"),
|
|
57
|
+
)
|
|
58
|
+
.min(1, "allowedMimeTypes must not be empty — omit the field to allow all types")
|
|
59
|
+
.max(64, "allowedMimeTypes may contain at most 64 entries")
|
|
60
|
+
.optional(),
|
|
52
61
|
})
|
|
53
62
|
.optional();
|
|
54
63
|
|
|
@@ -92,7 +101,7 @@ export const createFieldBody = z
|
|
|
92
101
|
required: z.boolean().optional(),
|
|
93
102
|
unique: z.boolean().optional(),
|
|
94
103
|
defaultValue: z.unknown().optional(),
|
|
95
|
-
validation: fieldValidation,
|
|
104
|
+
validation: fieldValidation.nullable(),
|
|
96
105
|
widget: z.string().optional(),
|
|
97
106
|
options: fieldWidgetOptions,
|
|
98
107
|
sortOrder: z.number().int().min(0).optional(),
|
|
@@ -107,7 +116,7 @@ export const updateFieldBody = z
|
|
|
107
116
|
required: z.boolean().optional(),
|
|
108
117
|
unique: z.boolean().optional(),
|
|
109
118
|
defaultValue: z.unknown().optional(),
|
|
110
|
-
validation: fieldValidation,
|
|
119
|
+
validation: fieldValidation.nullable(),
|
|
111
120
|
widget: z.string().optional(),
|
|
112
121
|
options: fieldWidgetOptions,
|
|
113
122
|
sortOrder: z.number().int().min(0).optional(),
|
package/src/astro/middleware.ts
CHANGED
|
@@ -47,7 +47,12 @@ import { createPublicMediaUrlResolver } from "../media/url.js";
|
|
|
47
47
|
import type { SandboxRunner } from "../plugins/sandbox/types.js";
|
|
48
48
|
import type { ResolvedPlugin } from "../plugins/types.js";
|
|
49
49
|
import { invalidateUrlPatternCache } from "../query.js";
|
|
50
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
createRequestMetrics,
|
|
52
|
+
getRequestContext,
|
|
53
|
+
type RequestMetrics,
|
|
54
|
+
runWithContext,
|
|
55
|
+
} from "../request-context.js";
|
|
51
56
|
import type { EmDashConfig } from "./integration/runtime.js";
|
|
52
57
|
import type { EmDashHandlers } from "./types.js";
|
|
53
58
|
|
|
@@ -209,6 +214,33 @@ function finalizeResponse(
|
|
|
209
214
|
return res;
|
|
210
215
|
}
|
|
211
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Append always-on counters (db.*, cache.*) to the Server-Timing list.
|
|
219
|
+
*
|
|
220
|
+
* dur values for `count`, `hit`, `miss` are integer counts — Server-Timing
|
|
221
|
+
* spec only models milliseconds, but browsers show whatever number is given,
|
|
222
|
+
* which is the convention most projects use for non-time samples.
|
|
223
|
+
*/
|
|
224
|
+
function pushMetricsTimings(
|
|
225
|
+
timings: Array<{ name: string; dur: number; desc?: string }>,
|
|
226
|
+
metrics: RequestMetrics,
|
|
227
|
+
): void {
|
|
228
|
+
if (metrics.dbCount > 0) {
|
|
229
|
+
timings.push({ name: "db.total", dur: metrics.dbTotalMs, desc: "DB total" });
|
|
230
|
+
timings.push({ name: "db.count", dur: metrics.dbCount, desc: "Query count" });
|
|
231
|
+
if (metrics.dbFirstOffset !== null) {
|
|
232
|
+
timings.push({ name: "db.first", dur: metrics.dbFirstOffset, desc: "First query at" });
|
|
233
|
+
}
|
|
234
|
+
if (metrics.dbLastOffset !== null) {
|
|
235
|
+
timings.push({ name: "db.last", dur: metrics.dbLastOffset, desc: "Last query at" });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (metrics.cacheHits + metrics.cacheMisses > 0) {
|
|
239
|
+
timings.push({ name: "cache.hit", dur: metrics.cacheHits, desc: "Cache hits" });
|
|
240
|
+
timings.push({ name: "cache.miss", dur: metrics.cacheMisses, desc: "Cache misses" });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
212
244
|
/** Public routes that require the runtime (sitemap, robots.txt, etc.) */
|
|
213
245
|
const PUBLIC_RUNTIME_ROUTES = new Set(["/sitemap.xml", "/robots.txt"]);
|
|
214
246
|
const SITEMAP_COLLECTION_RE = /^\/sitemap-[a-z][a-z0-9_]*\.xml$/;
|
|
@@ -252,6 +284,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
252
284
|
? createRecorder(url.pathname, request.method, request.headers.get("x-perf-phase") ?? "default")
|
|
253
285
|
: undefined;
|
|
254
286
|
|
|
287
|
+
const metrics = createRequestMetrics(performance.now());
|
|
288
|
+
|
|
255
289
|
const run = async (): Promise<Response> => {
|
|
256
290
|
// Process /_emdash routes and public routes with an active session
|
|
257
291
|
// (logged-in editors need the runtime for toolbar/visual editing on public pages)
|
|
@@ -355,13 +389,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
355
389
|
const response = await next();
|
|
356
390
|
timings.push({ name: "render", dur: performance.now() - t0, desc: "Page render" });
|
|
357
391
|
timings.push({ name: "mw", dur: performance.now() - mwStart, desc: "Total middleware" });
|
|
392
|
+
pushMetricsTimings(timings, metrics);
|
|
358
393
|
return finalizeResponse(response, timings);
|
|
359
394
|
};
|
|
360
395
|
if (anonScoped) {
|
|
361
396
|
const parent = getRequestContext();
|
|
362
397
|
const ctx = parent
|
|
363
398
|
? { ...parent, db: anonScoped.db }
|
|
364
|
-
: { editMode: false, db: anonScoped.db };
|
|
399
|
+
: { editMode: false, db: anonScoped.db, metrics };
|
|
365
400
|
return runWithContext(ctx, async () => {
|
|
366
401
|
const response = await runAnon();
|
|
367
402
|
anonScoped.commit();
|
|
@@ -516,12 +551,15 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
516
551
|
const response = await next();
|
|
517
552
|
timings.push({ name: "render", dur: performance.now() - t0, desc: "Page render" });
|
|
518
553
|
timings.push({ name: "mw", dur: performance.now() - mwStart, desc: "Total middleware" });
|
|
554
|
+
pushMetricsTimings(timings, metrics);
|
|
519
555
|
return finalizeResponse(response, timings);
|
|
520
556
|
};
|
|
521
557
|
|
|
522
558
|
if (scoped) {
|
|
523
559
|
const parent = getRequestContext();
|
|
524
|
-
const ctx = parent
|
|
560
|
+
const ctx = parent
|
|
561
|
+
? { ...parent, db: scoped.db }
|
|
562
|
+
: { editMode: false, db: scoped.db, metrics };
|
|
525
563
|
return runWithContext(ctx, async () => {
|
|
526
564
|
const response = await renderAndFinalize();
|
|
527
565
|
scoped.commit();
|
|
@@ -542,20 +580,17 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
542
580
|
const parent = getRequestContext();
|
|
543
581
|
const ctx = parent
|
|
544
582
|
? { ...parent, editMode, db: playgroundDb, dbIsIsolated: true }
|
|
545
|
-
: { editMode, db: playgroundDb, dbIsIsolated: true };
|
|
583
|
+
: { editMode, db: playgroundDb, dbIsIsolated: true, metrics };
|
|
546
584
|
return runWithContext(ctx, doInit);
|
|
547
585
|
}
|
|
548
586
|
return doInit();
|
|
549
587
|
};
|
|
550
588
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
flushRecorder(queryRecorder);
|
|
556
|
-
}
|
|
589
|
+
try {
|
|
590
|
+
return await runWithContext({ editMode: false, queryRecorder, metrics }, run);
|
|
591
|
+
} finally {
|
|
592
|
+
if (queryRecorder) flushRecorder(queryRecorder);
|
|
557
593
|
}
|
|
558
|
-
return run();
|
|
559
594
|
});
|
|
560
595
|
|
|
561
596
|
export default onRequest;
|
|
@@ -50,7 +50,7 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
|
|
|
50
50
|
|
|
51
51
|
if (!result.success) return unwrapResult(result);
|
|
52
52
|
|
|
53
|
-
if (cache
|
|
53
|
+
if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
|
|
54
54
|
|
|
55
55
|
return unwrapResult(result);
|
|
56
56
|
};
|
|
@@ -55,7 +55,7 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
|
|
|
55
55
|
|
|
56
56
|
if (!result.success) return unwrapResult(result);
|
|
57
57
|
|
|
58
|
-
if (cache
|
|
58
|
+
if (cache?.enabled) await cache.invalidate({ tags: [collection] });
|
|
59
59
|
|
|
60
60
|
return unwrapResult(result, 201);
|
|
61
61
|
};
|
|
@@ -27,7 +27,7 @@ export const DELETE: APIRoute = async ({ params, locals, cache }) => {
|
|
|
27
27
|
|
|
28
28
|
if (!result.success) return unwrapResult(result);
|
|
29
29
|
|
|
30
|
-
if (cache
|
|
30
|
+
if (cache?.enabled) await cache.invalidate({ tags: [collection, id] });
|
|
31
31
|
|
|
32
32
|
return unwrapResult(result);
|
|
33
33
|
};
|
|
@@ -80,7 +80,7 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
|
|
|
80
80
|
|
|
81
81
|
if (!result.success) return unwrapResult(result);
|
|
82
82
|
|
|
83
|
-
if (cache
|
|
83
|
+
if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
|
|
84
84
|
|
|
85
85
|
return unwrapResult(result);
|
|
86
86
|
};
|
|
@@ -50,7 +50,7 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
|
|
|
50
50
|
|
|
51
51
|
if (!result.success) return unwrapResult(result);
|
|
52
52
|
|
|
53
|
-
if (cache
|
|
53
|
+
if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
|
|
54
54
|
|
|
55
55
|
return unwrapResult(result);
|
|
56
56
|
};
|
|
@@ -63,7 +63,7 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
|
|
|
63
63
|
|
|
64
64
|
if (!result.success) return unwrapResult(result);
|
|
65
65
|
|
|
66
|
-
if (cache
|
|
66
|
+
if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId ?? id] });
|
|
67
67
|
|
|
68
68
|
return unwrapResult(result);
|
|
69
69
|
};
|
|
@@ -95,7 +95,7 @@ export const DELETE: APIRoute = async ({ params, locals, cache }) => {
|
|
|
95
95
|
|
|
96
96
|
if (!result.success) return unwrapResult(result);
|
|
97
97
|
|
|
98
|
-
if (cache
|
|
98
|
+
if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId ?? id] });
|
|
99
99
|
|
|
100
100
|
return unwrapResult(result);
|
|
101
101
|
};
|
|
@@ -50,7 +50,7 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
|
|
|
50
50
|
|
|
51
51
|
if (!result.success) return unwrapResult(result);
|
|
52
52
|
|
|
53
|
-
if (cache
|
|
53
|
+
if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
|
|
54
54
|
|
|
55
55
|
return unwrapResult(result);
|
|
56
56
|
};
|
|
@@ -125,7 +125,7 @@ export const PUT: APIRoute = async ({ params, request, locals, cache }) => {
|
|
|
125
125
|
|
|
126
126
|
if (!result.success) return unwrapResult(result);
|
|
127
127
|
|
|
128
|
-
if (cache
|
|
128
|
+
if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
|
|
129
129
|
|
|
130
130
|
return unwrapResult(result);
|
|
131
131
|
};
|
|
@@ -171,7 +171,7 @@ export const DELETE: APIRoute = async ({ params, locals, cache }) => {
|
|
|
171
171
|
|
|
172
172
|
if (!result.success) return unwrapResult(result);
|
|
173
173
|
|
|
174
|
-
if (cache
|
|
174
|
+
if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
|
|
175
175
|
|
|
176
176
|
return unwrapResult(result);
|
|
177
177
|
};
|
|
@@ -91,7 +91,7 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
|
|
|
91
91
|
|
|
92
92
|
if (!result.success) return unwrapResult(result);
|
|
93
93
|
|
|
94
|
-
if (cache
|
|
94
|
+
if (cache?.enabled) await cache.invalidate({ tags: [collection] });
|
|
95
95
|
|
|
96
96
|
return unwrapResult(result, 201);
|
|
97
97
|
};
|
|
@@ -15,8 +15,10 @@ import { ulid } from "ulidx";
|
|
|
15
15
|
|
|
16
16
|
import { requirePerm } from "#api/authorize.js";
|
|
17
17
|
import { apiError, apiSuccess, handleError } from "#api/error.js";
|
|
18
|
+
import { GLOBAL_UPLOAD_ALLOWLIST, resolveFieldAllowlist } from "#api/handlers/media-allowlist.js";
|
|
18
19
|
import { isParseError, parseBody } from "#api/parse.js";
|
|
19
20
|
import { DEFAULT_MAX_UPLOAD_SIZE, mediaUploadUrlBody } from "#api/schemas.js";
|
|
21
|
+
import { matchesMimeAllowlist, normalizeMime } from "#media/mime.js";
|
|
20
22
|
|
|
21
23
|
export const prerender = false;
|
|
22
24
|
|
|
@@ -70,9 +72,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
70
72
|
const body = await parseBody(request, mediaUploadUrlBody(maxSize));
|
|
71
73
|
if (isParseError(body)) return body;
|
|
72
74
|
|
|
73
|
-
// Validate content type
|
|
74
|
-
const
|
|
75
|
-
|
|
75
|
+
// Validate content type (field-aware widening)
|
|
76
|
+
const fieldAllowlist = body.fieldId
|
|
77
|
+
? await resolveFieldAllowlist(emdash.db, body.fieldId)
|
|
78
|
+
: null;
|
|
79
|
+
const allowlist = fieldAllowlist ?? [...GLOBAL_UPLOAD_ALLOWLIST];
|
|
80
|
+
|
|
81
|
+
if (!matchesMimeAllowlist(body.contentType, allowlist)) {
|
|
76
82
|
return apiError("INVALID_TYPE", "File type not allowed", 400);
|
|
77
83
|
}
|
|
78
84
|
|
|
@@ -100,7 +106,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
100
106
|
// Create pending media record with content hash
|
|
101
107
|
const mediaItem = await repo.createPending({
|
|
102
108
|
filename: body.filename,
|
|
103
|
-
mimeType: body.contentType,
|
|
109
|
+
mimeType: normalizeMime(body.contentType),
|
|
104
110
|
size: body.size,
|
|
105
111
|
storageKey,
|
|
106
112
|
contentHash: body.contentHash,
|
|
@@ -12,9 +12,11 @@ import { ulid } from "ulidx";
|
|
|
12
12
|
|
|
13
13
|
import { requirePerm } from "#api/authorize.js";
|
|
14
14
|
import { apiError, apiSuccess, handleError, unwrapResult } from "#api/error.js";
|
|
15
|
+
import { GLOBAL_UPLOAD_ALLOWLIST, resolveFieldAllowlist } from "#api/handlers/media-allowlist.js";
|
|
15
16
|
import { isParseError, parseQuery } from "#api/parse.js";
|
|
16
17
|
import { DEFAULT_MAX_UPLOAD_SIZE, formatFileSize, mediaListQuery } from "#api/schemas.js";
|
|
17
18
|
import { MediaRepository } from "#db/repositories/media.js";
|
|
19
|
+
import { matchesMimeAllowlist, normalizeMime } from "#media/mime.js";
|
|
18
20
|
import { generatePlaceholder } from "#media/placeholder.js";
|
|
19
21
|
import { computeContentHash } from "#utils/hash.js";
|
|
20
22
|
|
|
@@ -106,9 +108,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
106
108
|
return apiError("NO_FILE", "No file provided", 400);
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
// Validate file type
|
|
110
|
-
const
|
|
111
|
-
|
|
111
|
+
// Validate file type — widen the allowlist when a field-specific list is configured
|
|
112
|
+
const fieldIdEntry = formData.get("fieldId");
|
|
113
|
+
const fieldId =
|
|
114
|
+
typeof fieldIdEntry === "string" && fieldIdEntry.length > 0 ? fieldIdEntry : null;
|
|
115
|
+
|
|
116
|
+
const fieldAllowlist = fieldId ? await resolveFieldAllowlist(emdash.db, fieldId) : null;
|
|
117
|
+
const allowlist = fieldAllowlist ?? [...GLOBAL_UPLOAD_ALLOWLIST];
|
|
118
|
+
|
|
119
|
+
if (!matchesMimeAllowlist(file.type, allowlist)) {
|
|
112
120
|
return apiError("INVALID_TYPE", "File type not allowed", 400);
|
|
113
121
|
}
|
|
114
122
|
|
|
@@ -174,7 +182,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
174
182
|
// Create media record
|
|
175
183
|
const result = await emdash.handleMediaCreate({
|
|
176
184
|
filename: file.name,
|
|
177
|
-
mimeType: file.type,
|
|
185
|
+
mimeType: normalizeMime(file.type),
|
|
178
186
|
size: file.size,
|
|
179
187
|
width,
|
|
180
188
|
height,
|
package/src/astro/types.ts
CHANGED
|
@@ -43,6 +43,10 @@ export interface ManifestCollection {
|
|
|
43
43
|
* (e.g. a checkbox grid receiving its column definitions)
|
|
44
44
|
*/
|
|
45
45
|
options?: Array<{ value: string; label: string }> | Record<string, unknown>;
|
|
46
|
+
/** The `_emdash_fields` row ID. Used by the admin to forward to upload/media-list API calls. */
|
|
47
|
+
id?: string;
|
|
48
|
+
/** Validation config for the field (e.g. `allowedMimeTypes` for file/image fields, subFields for repeater). */
|
|
49
|
+
validation?: Record<string, unknown>;
|
|
46
50
|
}
|
|
47
51
|
>;
|
|
48
52
|
}
|
|
@@ -292,7 +296,7 @@ export interface EmDashHandlers {
|
|
|
292
296
|
handleMediaList: (params: {
|
|
293
297
|
cursor?: string;
|
|
294
298
|
limit?: number;
|
|
295
|
-
mimeType?: string;
|
|
299
|
+
mimeType?: string | readonly string[];
|
|
296
300
|
}) => Promise<HandlerResponse>;
|
|
297
301
|
|
|
298
302
|
handleMediaGet: (id: string) => Promise<HandlerResponse>;
|
package/src/auth/rate-limit.ts
CHANGED
|
@@ -63,9 +63,9 @@ export async function checkRateLimit(
|
|
|
63
63
|
|
|
64
64
|
// Atomic upsert: insert or increment, return current count
|
|
65
65
|
const result = await sql<{ count: number }>`
|
|
66
|
-
INSERT INTO _emdash_rate_limits (key, window, count)
|
|
66
|
+
INSERT INTO _emdash_rate_limits (key, "window", count)
|
|
67
67
|
VALUES (${key}, ${windowStart}, 1)
|
|
68
|
-
ON CONFLICT (key, window)
|
|
68
|
+
ON CONFLICT (key, "window")
|
|
69
69
|
DO UPDATE SET count = _emdash_rate_limits.count + 1
|
|
70
70
|
RETURNING count
|
|
71
71
|
`.execute(db);
|
|
@@ -179,7 +179,7 @@ export async function cleanupExpiredRateLimits(
|
|
|
179
179
|
const cutoff = new Date(Date.now() - maxAgeSeconds * 1000).toISOString();
|
|
180
180
|
|
|
181
181
|
const result = await sql`
|
|
182
|
-
DELETE FROM _emdash_rate_limits WHERE window < ${cutoff}
|
|
182
|
+
DELETE FROM _emdash_rate_limits WHERE "window" < ${cutoff}
|
|
183
183
|
`.execute(db);
|
|
184
184
|
|
|
185
185
|
return Number(result.numAffectedRows ?? 0);
|