emdash 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{adapters-BLMa4JGD.d.mts → adapters-C2BzVy0p.d.mts} +1 -1
- package/dist/{adapters-BLMa4JGD.d.mts.map → adapters-C2BzVy0p.d.mts.map} +1 -1
- package/dist/{apply-Bqoekfbe.mjs → apply-Cma_PiF6.mjs} +37 -22
- package/dist/apply-Cma_PiF6.mjs.map +1 -0
- package/dist/astro/index.d.mts +6 -6
- package/dist/astro/index.mjs +9 -8
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/middleware/auth.d.mts +5 -5
- package/dist/astro/middleware/auth.mjs +2 -2
- package/dist/astro/middleware/redirect.d.mts.map +1 -1
- package/dist/astro/middleware/redirect.mjs +19 -7
- package/dist/astro/middleware/redirect.mjs.map +1 -1
- package/dist/astro/middleware/request-context.mjs +12 -2
- package/dist/astro/middleware/request-context.mjs.map +1 -1
- package/dist/astro/middleware/setup.mjs +1 -1
- package/dist/astro/middleware.d.mts.map +1 -1
- package/dist/astro/middleware.mjs +45 -42
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +8 -8
- package/dist/{byline-BGj9p9Ht.mjs → byline-WuOq9MFJ.mjs} +3 -2
- package/dist/byline-WuOq9MFJ.mjs.map +1 -0
- package/dist/{bylines-BihaoIDY.mjs → bylines-C_Wsnz4L.mjs} +36 -4
- package/dist/bylines-C_Wsnz4L.mjs.map +1 -0
- package/dist/cache-E3Dts-yT.mjs +56 -0
- package/dist/cache-E3Dts-yT.mjs.map +1 -0
- package/dist/cli/index.mjs +11 -11
- package/dist/cli/index.mjs.map +1 -1
- package/dist/client/cf-access.d.mts +1 -1
- package/dist/client/index.d.mts +1 -1
- package/dist/client/index.mjs +1 -1
- package/dist/{config-Cq8H0SfX.mjs → config-DkxPrM9l.mjs} +1 -1
- package/dist/{config-Cq8H0SfX.mjs.map → config-DkxPrM9l.mjs.map} +1 -1
- package/dist/db/index.d.mts +3 -3
- package/dist/db/index.mjs +1 -1
- package/dist/db/libsql.d.mts +1 -1
- package/dist/db/postgres.d.mts +1 -1
- package/dist/db/sqlite.d.mts +1 -1
- package/dist/{default-WYlzADZL.mjs → default-PUx9RK6u.mjs} +1 -1
- package/dist/{default-WYlzADZL.mjs.map → default-PUx9RK6u.mjs.map} +1 -1
- package/dist/{error-DrxtnGPg.mjs → error-HBeQbVhV.mjs} +1 -1
- package/dist/{error-DrxtnGPg.mjs.map → error-HBeQbVhV.mjs.map} +1 -1
- package/dist/{index-Cff7AimE.d.mts → index-CRg3PWfZ.d.mts} +32 -30
- package/dist/index-CRg3PWfZ.d.mts.map +1 -0
- package/dist/index.d.mts +11 -11
- package/dist/index.mjs +17 -17
- package/dist/{load-Veizk2cT.mjs → load-BhSSm-TS.mjs} +1 -1
- package/dist/{load-Veizk2cT.mjs.map → load-BhSSm-TS.mjs.map} +1 -1
- package/dist/{loader-BmYdf3Dr.mjs → loader-BYzwzORf.mjs} +1 -1
- package/dist/{loader-BmYdf3Dr.mjs.map → loader-BYzwzORf.mjs.map} +1 -1
- package/dist/{manifest-schema-CuMio1A9.mjs → manifest-schema-BsXINkQD.mjs} +1 -1
- package/dist/{manifest-schema-CuMio1A9.mjs.map → manifest-schema-BsXINkQD.mjs.map} +1 -1
- package/dist/media/index.d.mts +1 -1
- package/dist/media/index.mjs +1 -1
- package/dist/media/local-runtime.d.mts +7 -7
- package/dist/{mode-C2EzN1uE.mjs → mode-CyPLdO3C.mjs} +1 -1
- package/dist/{mode-C2EzN1uE.mjs.map → mode-CyPLdO3C.mjs.map} +1 -1
- package/dist/page/index.d.mts +1 -1
- package/dist/patterns-CrCYkMBb.mjs +93 -0
- package/dist/patterns-CrCYkMBb.mjs.map +1 -0
- package/dist/{placeholder-SvFCKbz_.d.mts → placeholder-BBCtpTES.d.mts} +1 -1
- package/dist/{placeholder-SvFCKbz_.d.mts.map → placeholder-BBCtpTES.d.mts.map} +1 -1
- package/dist/{placeholder-aiCD8aSZ.mjs → placeholder-DntBEQo7.mjs} +1 -1
- package/dist/{placeholder-aiCD8aSZ.mjs.map → placeholder-DntBEQo7.mjs.map} +1 -1
- package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
- package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
- package/dist/{query-sesiOndV.mjs → query-B6Vu0d2i.mjs} +34 -15
- package/dist/{query-sesiOndV.mjs.map → query-B6Vu0d2i.mjs.map} +1 -1
- package/dist/{redirect-DUAk-Yl_.mjs → redirect-7lGhLBNZ.mjs} +2 -92
- package/dist/redirect-7lGhLBNZ.mjs.map +1 -0
- package/dist/{registry-DU18yVo0.mjs → registry-BgnP3ysR.mjs} +19 -35
- package/dist/registry-BgnP3ysR.mjs.map +1 -0
- package/dist/{runner-Biufrii2.mjs → runner-Cd-_WyDo.mjs} +16 -4
- package/dist/runner-Cd-_WyDo.mjs.map +1 -0
- package/dist/{runner-EAtf0ZIe.d.mts → runner-DYv3rX8P.d.mts} +10 -3
- package/dist/runner-DYv3rX8P.d.mts.map +1 -0
- package/dist/runtime.d.mts +6 -6
- package/dist/runtime.mjs +1 -1
- package/dist/{search-BXB-jfu2.mjs → search-B5p9D36n.mjs} +102 -53
- package/dist/search-B5p9D36n.mjs.map +1 -0
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +8 -8
- package/dist/seo/index.d.mts +1 -1
- package/dist/storage/local.d.mts +1 -1
- package/dist/storage/local.mjs +1 -1
- package/dist/storage/s3.d.mts +1 -1
- package/dist/storage/s3.mjs +1 -1
- package/dist/{tokens-DrB-W6Q-.mjs → tokens-DKHiCYCB.mjs} +1 -1
- package/dist/{tokens-DrB-W6Q-.mjs.map → tokens-DKHiCYCB.mjs.map} +1 -1
- package/dist/transaction-Cn2rjY78.mjs +28 -0
- package/dist/transaction-Cn2rjY78.mjs.map +1 -0
- package/dist/{transport-yxiQsi8I.mjs → transport-BtcQ-Z7T.mjs} +1 -1
- package/dist/{transport-yxiQsi8I.mjs.map → transport-BtcQ-Z7T.mjs.map} +1 -1
- package/dist/{transport-BFGblqwG.d.mts → transport-CKQA_G44.d.mts} +1 -1
- package/dist/{transport-BFGblqwG.d.mts.map → transport-CKQA_G44.d.mts.map} +1 -1
- package/dist/{types-DRjfYOEv.d.mts → types-B6BzlZxx.d.mts} +1 -1
- package/dist/{types-DRjfYOEv.d.mts.map → types-B6BzlZxx.d.mts.map} +1 -1
- package/dist/{types-CaKte3hR.d.mts → types-BYWYxLcp.d.mts} +10 -4
- package/dist/types-BYWYxLcp.d.mts.map +1 -0
- package/dist/{types-BbsYgi_R.d.mts → types-BmkQR1En.d.mts} +1 -1
- package/dist/{types-BbsYgi_R.d.mts.map → types-BmkQR1En.d.mts.map} +1 -1
- package/dist/{types-C1-PVaS_.d.mts → types-DNZpaCBk.d.mts} +1 -1
- package/dist/{types-C1-PVaS_.d.mts.map → types-DNZpaCBk.d.mts.map} +1 -1
- package/dist/{types-Bec-r_3_.mjs → types-Dz9_WMS6.mjs} +1 -1
- package/dist/{types-Bec-r_3_.mjs.map → types-Dz9_WMS6.mjs.map} +1 -1
- package/dist/{types-DPfzHnjW.d.mts → types-gLYVCXCQ.d.mts} +1 -1
- package/dist/{types-DPfzHnjW.d.mts.map → types-gLYVCXCQ.d.mts.map} +1 -1
- package/dist/{types-DuNbGKjF.mjs → types-xxCWI3j0.mjs} +1 -1
- package/dist/{types-DuNbGKjF.mjs.map → types-xxCWI3j0.mjs.map} +1 -1
- package/dist/{validate-bfg9OR6N.d.mts → validate-CcNRWH6I.d.mts} +4 -4
- package/dist/{validate-bfg9OR6N.d.mts.map → validate-CcNRWH6I.d.mts.map} +1 -1
- package/dist/{validate-CXnRKfJK.mjs → validate-DuZDIxfy.mjs} +2 -2
- package/dist/{validate-CXnRKfJK.mjs.map → validate-DuZDIxfy.mjs.map} +1 -1
- package/dist/version-DlTDRdpv.mjs +7 -0
- package/dist/{version-REAapfsU.mjs.map → version-DlTDRdpv.mjs.map} +1 -1
- package/package.json +7 -5
- package/src/api/handlers/content.ts +36 -25
- package/src/api/handlers/menus.ts +19 -16
- package/src/astro/integration/index.ts +2 -3
- package/src/astro/integration/runtime.ts +8 -14
- package/src/astro/integration/vite-config.ts +7 -0
- package/src/astro/middleware/redirect.ts +30 -15
- package/src/astro/middleware.ts +11 -19
- package/src/astro/routes/api/admin/bylines/[id]/index.ts +3 -0
- package/src/astro/routes/api/admin/bylines/index.ts +2 -0
- package/src/astro/routes/api/redirects/[id].ts +3 -0
- package/src/astro/routes/api/redirects/index.ts +2 -0
- package/src/astro/routes/api/schema/collections/[slug]/index.ts +2 -0
- package/src/astro/routes/api/schema/collections/index.ts +1 -0
- package/src/bylines/index.ts +48 -0
- package/src/cleanup.ts +3 -3
- package/src/cli/commands/bundle-utils.ts +5 -5
- package/src/database/migrations/011_sections.ts +2 -2
- package/src/database/migrations/runner.ts +23 -2
- package/src/database/repositories/byline.ts +2 -1
- package/src/emdash-runtime.ts +18 -8
- package/src/index.ts +2 -0
- package/src/mcp/server.ts +40 -67
- package/src/plugins/context.ts +28 -4
- package/src/plugins/cron.ts +29 -4
- package/src/plugins/hooks.ts +22 -10
- package/src/plugins/index.ts +1 -0
- package/src/plugins/manager.ts +6 -2
- package/src/plugins/marketplace.ts +33 -3
- package/src/plugins/routes.ts +3 -3
- package/src/plugins/types.ts +7 -0
- package/src/query.ts +37 -14
- package/src/redirects/cache.ts +68 -0
- package/src/search/fts-manager.ts +20 -11
- package/src/search/query.ts +8 -9
- package/src/seed/apply.ts +49 -28
- package/src/visual-editing/toolbar.ts +11 -1
- package/dist/apply-Bqoekfbe.mjs.map +0 -1
- package/dist/byline-BGj9p9Ht.mjs.map +0 -1
- package/dist/bylines-BihaoIDY.mjs.map +0 -1
- package/dist/index-Cff7AimE.d.mts.map +0 -1
- package/dist/redirect-DUAk-Yl_.mjs.map +0 -1
- package/dist/registry-DU18yVo0.mjs.map +0 -1
- package/dist/runner-Biufrii2.mjs.map +0 -1
- package/dist/runner-EAtf0ZIe.d.mts.map +0 -1
- package/dist/search-BXB-jfu2.mjs.map +0 -1
- package/dist/types-CaKte3hR.d.mts.map +0 -1
- package/dist/version-REAapfsU.mjs +0 -7
package/src/astro/middleware.ts
CHANGED
|
@@ -160,20 +160,15 @@ async function getRuntime(config: EmDashConfig): Promise<EmDashRuntime> {
|
|
|
160
160
|
* Baseline security headers applied to all responses.
|
|
161
161
|
* Admin routes get additional headers (strict CSP) from auth middleware.
|
|
162
162
|
*/
|
|
163
|
-
function setBaselineSecurityHeaders(response: Response):
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
"Permissions-Policy",
|
|
171
|
-
"camera=(), microphone=(), geolocation=(), payment=()",
|
|
172
|
-
);
|
|
173
|
-
// Prevent clickjacking (non-admin routes; admin CSP uses frame-ancestors)
|
|
174
|
-
if (!response.headers.has("Content-Security-Policy")) {
|
|
175
|
-
response.headers.set("X-Frame-Options", "SAMEORIGIN");
|
|
163
|
+
function setBaselineSecurityHeaders(response: Response): Response {
|
|
164
|
+
const res = new Response(response.body, response);
|
|
165
|
+
res.headers.set("X-Content-Type-Options", "nosniff");
|
|
166
|
+
res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
167
|
+
res.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()");
|
|
168
|
+
if (!res.headers.has("Content-Security-Policy")) {
|
|
169
|
+
res.headers.set("X-Frame-Options", "SAMEORIGIN");
|
|
176
170
|
}
|
|
171
|
+
return res;
|
|
177
172
|
}
|
|
178
173
|
|
|
179
174
|
/** Public routes that require the runtime (sitemap, robots.txt, etc.) */
|
|
@@ -245,8 +240,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
245
240
|
}
|
|
246
241
|
|
|
247
242
|
const response = await next();
|
|
248
|
-
setBaselineSecurityHeaders(response);
|
|
249
|
-
return response;
|
|
243
|
+
return setBaselineSecurityHeaders(response);
|
|
250
244
|
}
|
|
251
245
|
}
|
|
252
246
|
|
|
@@ -416,8 +410,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
416
410
|
|
|
417
411
|
// Wrap the request in ALS with the per-request db
|
|
418
412
|
return runWithContext({ editMode: false, db: sessionDb }, async () => {
|
|
419
|
-
const response = await next();
|
|
420
|
-
setBaselineSecurityHeaders(response);
|
|
413
|
+
const response = setBaselineSecurityHeaders(await next());
|
|
421
414
|
|
|
422
415
|
// Set bookmark cookie for authenticated users only — they need
|
|
423
416
|
// read-your-writes consistency across requests. Anonymous visitors
|
|
@@ -445,8 +438,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
445
438
|
}
|
|
446
439
|
|
|
447
440
|
const response = await next();
|
|
448
|
-
setBaselineSecurityHeaders(response);
|
|
449
|
-
return response;
|
|
441
|
+
return setBaselineSecurityHeaders(response);
|
|
450
442
|
}; // end doInit
|
|
451
443
|
|
|
452
444
|
if (playgroundDb) {
|
|
@@ -5,6 +5,7 @@ import { requirePerm } from "#api/authorize.js";
|
|
|
5
5
|
import { apiError, apiSuccess, handleError } from "#api/error.js";
|
|
6
6
|
import { isParseError, parseBody } from "#api/parse.js";
|
|
7
7
|
import { bylineUpdateBody } from "#api/schemas.js";
|
|
8
|
+
import { invalidateBylineCache } from "#bylines/index.js";
|
|
8
9
|
import { BylineRepository } from "#db/repositories/byline.js";
|
|
9
10
|
|
|
10
11
|
export const prerender = false;
|
|
@@ -61,6 +62,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
|
|
|
61
62
|
});
|
|
62
63
|
|
|
63
64
|
if (!byline) return apiError("NOT_FOUND", "Byline not found", 404);
|
|
65
|
+
invalidateBylineCache();
|
|
64
66
|
return apiSuccess(byline);
|
|
65
67
|
} catch (error) {
|
|
66
68
|
return handleError(error, "Failed to update byline", "BYLINE_UPDATE_ERROR");
|
|
@@ -80,6 +82,7 @@ export const DELETE: APIRoute = async ({ params, locals }) => {
|
|
|
80
82
|
const repo = new BylineRepository(emdash.db);
|
|
81
83
|
const deleted = await repo.delete(params.id!);
|
|
82
84
|
if (!deleted) return apiError("NOT_FOUND", "Byline not found", 404);
|
|
85
|
+
invalidateBylineCache();
|
|
83
86
|
return apiSuccess({ deleted: true });
|
|
84
87
|
} catch (error) {
|
|
85
88
|
return handleError(error, "Failed to delete byline", "BYLINE_DELETE_ERROR");
|
|
@@ -5,6 +5,7 @@ import { requirePerm } from "#api/authorize.js";
|
|
|
5
5
|
import { apiError, apiSuccess, handleError } from "#api/error.js";
|
|
6
6
|
import { isParseError, parseBody, parseQuery } from "#api/parse.js";
|
|
7
7
|
import { bylineCreateBody, bylinesListQuery } from "#api/schemas.js";
|
|
8
|
+
import { invalidateBylineCache } from "#bylines/index.js";
|
|
8
9
|
import { BylineRepository } from "#db/repositories/byline.js";
|
|
9
10
|
|
|
10
11
|
export const prerender = false;
|
|
@@ -65,6 +66,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
65
66
|
isGuest: body.isGuest,
|
|
66
67
|
});
|
|
67
68
|
|
|
69
|
+
invalidateBylineCache();
|
|
68
70
|
return apiSuccess(byline, 201);
|
|
69
71
|
} catch (error) {
|
|
70
72
|
return handleError(error, "Failed to create byline", "BYLINE_CREATE_ERROR");
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "#api/handlers/redirects.js";
|
|
18
18
|
import { isParseError, parseBody } from "#api/parse.js";
|
|
19
19
|
import { updateRedirectBody } from "#api/schemas.js";
|
|
20
|
+
import { invalidateRedirectCache } from "#redirects/cache.js";
|
|
20
21
|
|
|
21
22
|
export const prerender = false;
|
|
22
23
|
|
|
@@ -57,6 +58,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
|
|
|
57
58
|
if (isParseError(body)) return body;
|
|
58
59
|
|
|
59
60
|
const result = await handleRedirectUpdate(db, id, body);
|
|
61
|
+
invalidateRedirectCache();
|
|
60
62
|
return unwrapResult(result);
|
|
61
63
|
} catch (error) {
|
|
62
64
|
return handleError(error, "Failed to update redirect", "REDIRECT_UPDATE_ERROR");
|
|
@@ -77,6 +79,7 @@ export const DELETE: APIRoute = async ({ params, locals }) => {
|
|
|
77
79
|
|
|
78
80
|
try {
|
|
79
81
|
const result = await handleRedirectDelete(db, id);
|
|
82
|
+
invalidateRedirectCache();
|
|
80
83
|
return unwrapResult(result);
|
|
81
84
|
} catch (error) {
|
|
82
85
|
return handleError(error, "Failed to delete redirect", "REDIRECT_DELETE_ERROR");
|
|
@@ -12,6 +12,7 @@ import { handleError, unwrapResult } from "#api/error.js";
|
|
|
12
12
|
import { handleRedirectCreate, handleRedirectList } from "#api/handlers/redirects.js";
|
|
13
13
|
import { isParseError, parseBody, parseQuery } from "#api/parse.js";
|
|
14
14
|
import { createRedirectBody, redirectsListQuery } from "#api/schemas.js";
|
|
15
|
+
import { invalidateRedirectCache } from "#redirects/cache.js";
|
|
15
16
|
|
|
16
17
|
export const prerender = false;
|
|
17
18
|
|
|
@@ -45,6 +46,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
45
46
|
if (isParseError(body)) return body;
|
|
46
47
|
|
|
47
48
|
const result = await handleRedirectCreate(db, body);
|
|
49
|
+
invalidateRedirectCache();
|
|
48
50
|
return unwrapResult(result, 201);
|
|
49
51
|
} catch (error) {
|
|
50
52
|
return handleError(error, "Failed to create redirect", "REDIRECT_CREATE_ERROR");
|
|
@@ -59,6 +59,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
|
|
|
59
59
|
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- parseBody validates via Zod
|
|
60
60
|
body as UpdateCollectionInput,
|
|
61
61
|
);
|
|
62
|
+
emdash!.invalidateManifest();
|
|
62
63
|
return unwrapResult(result);
|
|
63
64
|
};
|
|
64
65
|
|
|
@@ -76,5 +77,6 @@ export const DELETE: APIRoute = async ({ params, url, locals }) => {
|
|
|
76
77
|
const result = await handleSchemaCollectionDelete(emdash!.db, slug, {
|
|
77
78
|
force,
|
|
78
79
|
});
|
|
80
|
+
emdash!.invalidateManifest();
|
|
79
81
|
return unwrapResult(result);
|
|
80
82
|
};
|
|
@@ -43,5 +43,6 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
43
43
|
|
|
44
44
|
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Zod schema output narrowed to CreateCollectionInput
|
|
45
45
|
const result = await handleSchemaCollectionCreate(emdash!.db, body as CreateCollectionInput);
|
|
46
|
+
emdash!.invalidateManifest();
|
|
46
47
|
return unwrapResult(result, 201);
|
|
47
48
|
};
|
package/src/bylines/index.ts
CHANGED
|
@@ -14,6 +14,48 @@ import { validateIdentifier } from "../database/validate.js";
|
|
|
14
14
|
import { getDb } from "../loader.js";
|
|
15
15
|
import { chunks, SQL_BATCH_SIZE } from "../utils/chunks.js";
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Cached result of "does any byline exist in the database?"
|
|
19
|
+
* null = not yet checked, true/false = cached result.
|
|
20
|
+
* Invalidated when bylines are created or deleted.
|
|
21
|
+
*/
|
|
22
|
+
let hasBylines: boolean | null = null;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Invalidate the cached "has any bylines" check.
|
|
26
|
+
* Call this when bylines are created, updated, or deleted.
|
|
27
|
+
*/
|
|
28
|
+
export function invalidateBylineCache(): void {
|
|
29
|
+
hasBylines = null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if any bylines exist in the database. Result is cached
|
|
34
|
+
* for the lifetime of the worker/process and invalidated on writes.
|
|
35
|
+
*/
|
|
36
|
+
async function hasAnyBylines(): Promise<boolean> {
|
|
37
|
+
if (hasBylines !== null) return hasBylines;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const db = await getDb();
|
|
41
|
+
const result = await sql<{ id: string }>`
|
|
42
|
+
SELECT id FROM _emdash_bylines LIMIT 1
|
|
43
|
+
`.execute(db);
|
|
44
|
+
hasBylines = result.rows.length > 0;
|
|
45
|
+
} catch (error: unknown) {
|
|
46
|
+
// Only treat "no such table" as a safe false -- anything else should
|
|
47
|
+
// not be cached so the next request retries.
|
|
48
|
+
const message = error instanceof Error ? error.message : "";
|
|
49
|
+
if (message.includes("no such table")) {
|
|
50
|
+
hasBylines = false;
|
|
51
|
+
} else {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return hasBylines;
|
|
57
|
+
}
|
|
58
|
+
|
|
17
59
|
/**
|
|
18
60
|
* Get a byline by ID.
|
|
19
61
|
*
|
|
@@ -134,6 +176,12 @@ export async function getBylinesForEntries(
|
|
|
134
176
|
return result;
|
|
135
177
|
}
|
|
136
178
|
|
|
179
|
+
// Skip DB queries entirely when no bylines have been created.
|
|
180
|
+
// The cache is invalidated when bylines are created/deleted.
|
|
181
|
+
if (!(await hasAnyBylines())) {
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
|
|
137
185
|
const db = await getDb();
|
|
138
186
|
const repo = new BylineRepository(db);
|
|
139
187
|
|
package/src/cleanup.ts
CHANGED
|
@@ -121,11 +121,11 @@ export async function runSystemCleanup(
|
|
|
121
121
|
* them down to REVISION_KEEP_COUNT.
|
|
122
122
|
*/
|
|
123
123
|
async function pruneExcessiveRevisions(db: Kysely<Database>): Promise<number> {
|
|
124
|
-
const entries = await sql<{ collection: string; entry_id: string
|
|
125
|
-
SELECT collection, entry_id
|
|
124
|
+
const entries = await sql<{ collection: string; entry_id: string }>`
|
|
125
|
+
SELECT collection, entry_id
|
|
126
126
|
FROM revisions
|
|
127
127
|
GROUP BY collection, entry_id
|
|
128
|
-
HAVING
|
|
128
|
+
HAVING COUNT(*) > ${REVISION_PRUNE_THRESHOLD}
|
|
129
129
|
`.execute(db);
|
|
130
130
|
|
|
131
131
|
if (entries.rows.length === 0) return 0;
|
|
@@ -196,11 +196,7 @@ export async function resolveSourceEntry(
|
|
|
196
196
|
): Promise<string | undefined> {
|
|
197
197
|
const cleaned = distPath.replace(LEADING_DOT_SLASH_RE, "");
|
|
198
198
|
|
|
199
|
-
//
|
|
200
|
-
const direct = resolve(pluginDir, cleaned);
|
|
201
|
-
if (await fileExists(direct)) return direct;
|
|
202
|
-
|
|
203
|
-
// Convert dist path to src: dist/foo.mjs → src/foo.ts
|
|
199
|
+
// Prefer source over dist — dist/foo.mjs → src/foo.ts
|
|
204
200
|
const srcPath = cleaned.replace(DIST_PREFIX_RE, "src/").replace(MJS_EXT_RE, ".ts");
|
|
205
201
|
const srcFull = resolve(pluginDir, srcPath);
|
|
206
202
|
if (await fileExists(srcFull)) return srcFull;
|
|
@@ -210,6 +206,10 @@ export async function resolveSourceEntry(
|
|
|
210
206
|
const tsxFull = resolve(pluginDir, tsxPath);
|
|
211
207
|
if (await fileExists(tsxFull)) return tsxFull;
|
|
212
208
|
|
|
209
|
+
// Fall back to direct path (might be source already, or pre-compiled plugin)
|
|
210
|
+
const direct = resolve(pluginDir, cleaned);
|
|
211
|
+
if (await fileExists(direct)) return direct;
|
|
212
|
+
|
|
213
213
|
return undefined;
|
|
214
214
|
}
|
|
215
215
|
|
|
@@ -58,8 +58,8 @@ export async function up(db: Kysely<unknown>): Promise<void> {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
61
|
-
await db.schema.dropIndex("
|
|
62
|
-
await db.schema.dropIndex("
|
|
61
|
+
await db.schema.dropIndex("idx_sections_source").execute();
|
|
62
|
+
await db.schema.dropIndex("idx_sections_category").execute();
|
|
63
63
|
await db.schema.dropTable("_emdash_sections").execute();
|
|
64
64
|
await db.schema.dropTable("_emdash_section_categories").execute();
|
|
65
65
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Kysely, type Migration, type MigrationProvider, Migrator } from "kysely";
|
|
1
|
+
import { type Kysely, type Migration, type MigrationProvider, Migrator, sql } from "kysely";
|
|
2
2
|
|
|
3
3
|
import type { Database } from "../types.js";
|
|
4
4
|
// Import migrations statically for bundling
|
|
@@ -122,9 +122,30 @@ export async function getMigrationStatus(db: Kysely<Database>): Promise<Migratio
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
|
-
* Run all pending migrations
|
|
125
|
+
* Run all pending migrations.
|
|
126
|
+
*
|
|
127
|
+
* Includes a fast-path: if the migration table already exists and contains
|
|
128
|
+
* exactly MIGRATION_COUNT rows, all migrations have been applied and we can
|
|
129
|
+
* skip the Kysely Migrator entirely. This avoids the expensive
|
|
130
|
+
* `pragma_table_info` introspection that Kysely runs for every table in the
|
|
131
|
+
* database (twice!) just to check if the migration tables exist.
|
|
132
|
+
* On D1 with ~57 tables, that's ~116 queries saved per init.
|
|
126
133
|
*/
|
|
127
134
|
export async function runMigrations(db: Kysely<Database>): Promise<{ applied: string[] }> {
|
|
135
|
+
// Fast path: check if all migrations are already applied.
|
|
136
|
+
// A single cheap query vs the Migrator's full schema introspection.
|
|
137
|
+
try {
|
|
138
|
+
const result = await sql<{ count: number }>`
|
|
139
|
+
SELECT COUNT(*) as count FROM ${sql.ref(MIGRATION_TABLE)}
|
|
140
|
+
`.execute(db);
|
|
141
|
+
if (result.rows[0]?.count === MIGRATION_COUNT) {
|
|
142
|
+
return { applied: [] };
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// Table doesn't exist yet (first run). Fall through to the Migrator
|
|
146
|
+
// which will create it.
|
|
147
|
+
}
|
|
148
|
+
|
|
128
149
|
const migrator = new Migrator({
|
|
129
150
|
db,
|
|
130
151
|
provider: new StaticMigrationProvider(),
|
|
@@ -3,6 +3,7 @@ import { ulid } from "ulidx";
|
|
|
3
3
|
|
|
4
4
|
import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js";
|
|
5
5
|
import { listTablesLike } from "../dialect-helpers.js";
|
|
6
|
+
import { withTransaction } from "../transaction.js";
|
|
6
7
|
import type { BylineTable, Database } from "../types.js";
|
|
7
8
|
import { validateIdentifier } from "../validate.js";
|
|
8
9
|
import {
|
|
@@ -197,7 +198,7 @@ export class BylineRepository {
|
|
|
197
198
|
const existing = await this.findById(id);
|
|
198
199
|
if (!existing) return false;
|
|
199
200
|
|
|
200
|
-
await this.db
|
|
201
|
+
await withTransaction(this.db, async (trx) => {
|
|
201
202
|
await trx.deleteFrom("_emdash_content_bylines").where("byline_id", "=", id).execute();
|
|
202
203
|
|
|
203
204
|
await trx.deleteFrom("_emdash_bylines").where("id", "=", id).execute();
|
package/src/emdash-runtime.ts
CHANGED
|
@@ -37,6 +37,7 @@ import type {
|
|
|
37
37
|
PageMetadataContribution,
|
|
38
38
|
PageFragmentContribution,
|
|
39
39
|
} from "./plugins/types.js";
|
|
40
|
+
import { invalidateUrlPatternCache } from "./query.js";
|
|
40
41
|
import type { FieldType } from "./schema/types.js";
|
|
41
42
|
import { hashString } from "./utils/hash.js";
|
|
42
43
|
import { COMMIT, VERSION } from "./version.js";
|
|
@@ -57,6 +58,7 @@ const VALID_LINK_REL = new Set([
|
|
|
57
58
|
"alternate",
|
|
58
59
|
"author",
|
|
59
60
|
"license",
|
|
61
|
+
"nlweb",
|
|
60
62
|
"site.standard.document",
|
|
61
63
|
]);
|
|
62
64
|
|
|
@@ -1367,11 +1369,12 @@ export class EmDashRuntime {
|
|
|
1367
1369
|
}
|
|
1368
1370
|
|
|
1369
1371
|
/**
|
|
1370
|
-
* Invalidate
|
|
1371
|
-
*
|
|
1372
|
+
* Invalidate cached data derived from the manifest/schema.
|
|
1373
|
+
* Called when collections are created, updated, or deleted.
|
|
1372
1374
|
*/
|
|
1373
1375
|
invalidateManifest(): void {
|
|
1374
|
-
//
|
|
1376
|
+
// Invalidate the URL pattern cache used by resolveEmDashPath
|
|
1377
|
+
invalidateUrlPatternCache();
|
|
1375
1378
|
}
|
|
1376
1379
|
|
|
1377
1380
|
// =========================================================================
|
|
@@ -1613,7 +1616,7 @@ export class EmDashRuntime {
|
|
|
1613
1616
|
|
|
1614
1617
|
// Run afterDelete hooks (fire-and-forget)
|
|
1615
1618
|
if (result.success) {
|
|
1616
|
-
this.runAfterDeleteHooks(id, collection);
|
|
1619
|
+
this.runAfterDeleteHooks(id, collection, false);
|
|
1617
1620
|
}
|
|
1618
1621
|
|
|
1619
1622
|
return result;
|
|
@@ -1635,7 +1638,14 @@ export class EmDashRuntime {
|
|
|
1635
1638
|
}
|
|
1636
1639
|
|
|
1637
1640
|
async handleContentPermanentDelete(collection: string, id: string) {
|
|
1638
|
-
|
|
1641
|
+
const result = await handleContentPermanentDelete(this.db, collection, id);
|
|
1642
|
+
|
|
1643
|
+
// Run afterDelete hooks so plugins (e.g. AI Search) can clean up
|
|
1644
|
+
if (result.success) {
|
|
1645
|
+
this.runAfterDeleteHooks(id, collection, true);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
return result;
|
|
1639
1649
|
}
|
|
1640
1650
|
|
|
1641
1651
|
async handleContentCountTrashed(collection: string) {
|
|
@@ -2010,11 +2020,11 @@ export class EmDashRuntime {
|
|
|
2010
2020
|
}
|
|
2011
2021
|
}
|
|
2012
2022
|
|
|
2013
|
-
private runAfterDeleteHooks(id: string, collection: string): void {
|
|
2023
|
+
private runAfterDeleteHooks(id: string, collection: string, permanent: boolean): void {
|
|
2014
2024
|
// Trusted plugins
|
|
2015
2025
|
if (this.hooks.hasHooks("content:afterDelete")) {
|
|
2016
2026
|
this.hooks
|
|
2017
|
-
.runContentAfterDelete(id, collection)
|
|
2027
|
+
.runContentAfterDelete(id, collection, permanent)
|
|
2018
2028
|
.catch((err) => console.error("EmDash afterDelete hook error:", err));
|
|
2019
2029
|
}
|
|
2020
2030
|
|
|
@@ -2024,7 +2034,7 @@ export class EmDashRuntime {
|
|
|
2024
2034
|
if (!pluginId || !this.isPluginEnabled(pluginId)) continue;
|
|
2025
2035
|
|
|
2026
2036
|
plugin
|
|
2027
|
-
.invokeHook("content:afterDelete", { id, collection })
|
|
2037
|
+
.invokeHook("content:afterDelete", { id, collection, permanent })
|
|
2028
2038
|
.catch((err) =>
|
|
2029
2039
|
console.error(`EmDash: Sandboxed plugin ${pluginId} afterDelete error:`, err),
|
|
2030
2040
|
);
|
package/src/index.ts
CHANGED
package/src/mcp/server.ts
CHANGED
|
@@ -1257,26 +1257,8 @@ export function createMcpServer(): McpServer {
|
|
|
1257
1257
|
requireScope(extra, "content:read");
|
|
1258
1258
|
const ec = getEmDash(extra);
|
|
1259
1259
|
try {
|
|
1260
|
-
const
|
|
1261
|
-
|
|
1262
|
-
.selectAll()
|
|
1263
|
-
.execute()) as Array<{
|
|
1264
|
-
id: string;
|
|
1265
|
-
name: string;
|
|
1266
|
-
label: string;
|
|
1267
|
-
label_singular: string | null;
|
|
1268
|
-
hierarchical: number;
|
|
1269
|
-
collections: string | null;
|
|
1270
|
-
}>;
|
|
1271
|
-
const taxonomies = rows.map((row) => ({
|
|
1272
|
-
id: row.id,
|
|
1273
|
-
name: row.name,
|
|
1274
|
-
label: row.label,
|
|
1275
|
-
labelSingular: row.label_singular ?? undefined,
|
|
1276
|
-
hierarchical: row.hierarchical === 1,
|
|
1277
|
-
collections: row.collections ? JSON.parse(row.collections) : [],
|
|
1278
|
-
}));
|
|
1279
|
-
return jsonResult(taxonomies);
|
|
1260
|
+
const { handleTaxonomyList } = await import("../api/handlers/taxonomies.js");
|
|
1261
|
+
return unwrap(await handleTaxonomyList(ec.db));
|
|
1280
1262
|
} catch (error) {
|
|
1281
1263
|
return errorResult(error);
|
|
1282
1264
|
}
|
|
@@ -1302,32 +1284,44 @@ export function createMcpServer(): McpServer {
|
|
|
1302
1284
|
requireScope(extra, "content:read");
|
|
1303
1285
|
const ec = getEmDash(extra);
|
|
1304
1286
|
try {
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1287
|
+
// Verify taxonomy exists via handler layer
|
|
1288
|
+
const { handleTaxonomyList } = await import("../api/handlers/taxonomies.js");
|
|
1289
|
+
const listResult = await handleTaxonomyList(ec.db);
|
|
1290
|
+
if (!listResult.success) return unwrap(listResult);
|
|
1291
|
+
|
|
1292
|
+
const taxonomies = (listResult.data as { taxonomies: Array<{ name: string; id?: string }> })
|
|
1293
|
+
.taxonomies;
|
|
1294
|
+
const taxonomy = taxonomies.find((t: { name: string }) => t.name === args.taxonomy);
|
|
1311
1295
|
if (!taxonomy) return errorResult(`Taxonomy '${args.taxonomy}' not found`);
|
|
1312
1296
|
|
|
1297
|
+
// Paginated term query via repository (avoids N+1 of handleTermList)
|
|
1298
|
+
const { TaxonomyRepository } = await import("../database/repositories/taxonomy.js");
|
|
1299
|
+
const repo = new TaxonomyRepository(ec.db);
|
|
1313
1300
|
const limit = Math.min(args.limit ?? 50, 100);
|
|
1314
|
-
|
|
1315
|
-
.selectFrom("_emdash_taxonomy_terms" as never)
|
|
1316
|
-
.selectAll()
|
|
1317
|
-
.where("taxonomy_id" as never, "=", taxonomy.id as never)
|
|
1318
|
-
.orderBy("label" as never, "asc")
|
|
1319
|
-
.limit(limit + 1);
|
|
1301
|
+
const terms = await repo.findByName(args.taxonomy);
|
|
1320
1302
|
|
|
1303
|
+
// Manual cursor pagination over the sorted results
|
|
1304
|
+
let startIdx = 0;
|
|
1321
1305
|
if (args.cursor) {
|
|
1322
|
-
|
|
1306
|
+
const cursorIdx = terms.findIndex((t) => t.id === args.cursor);
|
|
1307
|
+
if (cursorIdx >= 0) startIdx = cursorIdx + 1;
|
|
1323
1308
|
}
|
|
1324
1309
|
|
|
1325
|
-
const
|
|
1326
|
-
const hasMore =
|
|
1327
|
-
const
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1310
|
+
const page = terms.slice(startIdx, startIdx + limit);
|
|
1311
|
+
const hasMore = startIdx + limit < terms.length;
|
|
1312
|
+
const nextCursor = hasMore ? page.at(-1)?.id : undefined;
|
|
1313
|
+
|
|
1314
|
+
return jsonResult({
|
|
1315
|
+
items: page.map((t) => ({
|
|
1316
|
+
id: t.id,
|
|
1317
|
+
name: t.name,
|
|
1318
|
+
slug: t.slug,
|
|
1319
|
+
label: t.label,
|
|
1320
|
+
parentId: t.parentId,
|
|
1321
|
+
description: typeof t.data?.description === "string" ? t.data.description : undefined,
|
|
1322
|
+
})),
|
|
1323
|
+
nextCursor,
|
|
1324
|
+
});
|
|
1331
1325
|
} catch (error) {
|
|
1332
1326
|
return errorResult(error);
|
|
1333
1327
|
}
|
|
@@ -1354,36 +1348,15 @@ export function createMcpServer(): McpServer {
|
|
|
1354
1348
|
requireRole(extra, Role.EDITOR);
|
|
1355
1349
|
const ec = getEmDash(extra);
|
|
1356
1350
|
try {
|
|
1357
|
-
const {
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
.selectFrom("_emdash_taxonomy_defs" as never)
|
|
1361
|
-
.select("id" as never)
|
|
1362
|
-
.where("name" as never, "=", args.taxonomy as never)
|
|
1363
|
-
.executeTakeFirst()) as { id: string } | undefined;
|
|
1364
|
-
|
|
1365
|
-
if (!taxonomy) return errorResult(`Taxonomy '${args.taxonomy}' not found`);
|
|
1366
|
-
|
|
1367
|
-
const id = ulid();
|
|
1368
|
-
await ec.db
|
|
1369
|
-
.insertInto("_emdash_taxonomy_terms" as never)
|
|
1370
|
-
.values({
|
|
1371
|
-
id,
|
|
1372
|
-
taxonomy_id: taxonomy.id,
|
|
1351
|
+
const { handleTermCreate } = await import("../api/handlers/taxonomies.js");
|
|
1352
|
+
return unwrap(
|
|
1353
|
+
await handleTermCreate(ec.db, args.taxonomy, {
|
|
1373
1354
|
slug: args.slug,
|
|
1374
1355
|
label: args.label,
|
|
1375
|
-
|
|
1376
|
-
description: args.description
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
const term = await ec.db
|
|
1381
|
-
.selectFrom("_emdash_taxonomy_terms" as never)
|
|
1382
|
-
.selectAll()
|
|
1383
|
-
.where("id" as never, "=", id as never)
|
|
1384
|
-
.executeTakeFirstOrThrow();
|
|
1385
|
-
|
|
1386
|
-
return jsonResult(term);
|
|
1356
|
+
parentId: args.parentId,
|
|
1357
|
+
description: args.description,
|
|
1358
|
+
}),
|
|
1359
|
+
);
|
|
1387
1360
|
} catch (error) {
|
|
1388
1361
|
return errorResult(error);
|
|
1389
1362
|
}
|
package/src/plugins/context.ts
CHANGED
|
@@ -202,9 +202,13 @@ export function createContentAccess(db: Kysely<Database>): ContentAccess {
|
|
|
202
202
|
const result: ContentItem = {
|
|
203
203
|
id: item.id,
|
|
204
204
|
type: item.type,
|
|
205
|
+
slug: item.slug,
|
|
206
|
+
status: item.status,
|
|
205
207
|
data: item.data,
|
|
206
208
|
createdAt: item.createdAt,
|
|
207
209
|
updatedAt: item.updatedAt,
|
|
210
|
+
locale: item.locale,
|
|
211
|
+
publishedAt: item.publishedAt,
|
|
208
212
|
};
|
|
209
213
|
|
|
210
214
|
if (await seoRepo.isEnabled(collection)) {
|
|
@@ -237,9 +241,13 @@ export function createContentAccess(db: Kysely<Database>): ContentAccess {
|
|
|
237
241
|
const items: ContentItem[] = result.items.map((item) => ({
|
|
238
242
|
id: item.id,
|
|
239
243
|
type: item.type,
|
|
244
|
+
slug: item.slug,
|
|
245
|
+
status: item.status,
|
|
240
246
|
data: item.data,
|
|
241
247
|
createdAt: item.createdAt,
|
|
242
248
|
updatedAt: item.updatedAt,
|
|
249
|
+
locale: item.locale,
|
|
250
|
+
publishedAt: item.publishedAt,
|
|
243
251
|
}));
|
|
244
252
|
|
|
245
253
|
if (items.length > 0 && (await seoRepo.isEnabled(collection))) {
|
|
@@ -294,9 +302,13 @@ export function createContentAccessWithWrite(db: Kysely<Database>): ContentAcces
|
|
|
294
302
|
const result: ContentItem = {
|
|
295
303
|
id: item.id,
|
|
296
304
|
type: item.type,
|
|
305
|
+
slug: item.slug,
|
|
306
|
+
status: item.status,
|
|
297
307
|
data: item.data,
|
|
298
308
|
createdAt: item.createdAt,
|
|
299
309
|
updatedAt: item.updatedAt,
|
|
310
|
+
locale: item.locale,
|
|
311
|
+
publishedAt: item.publishedAt,
|
|
300
312
|
};
|
|
301
313
|
|
|
302
314
|
if (hasSeo) {
|
|
@@ -336,9 +348,13 @@ export function createContentAccessWithWrite(db: Kysely<Database>): ContentAcces
|
|
|
336
348
|
const result: ContentItem = {
|
|
337
349
|
id: item.id,
|
|
338
350
|
type: item.type,
|
|
351
|
+
slug: item.slug,
|
|
352
|
+
status: item.status,
|
|
339
353
|
data: item.data,
|
|
340
354
|
createdAt: item.createdAt,
|
|
341
355
|
updatedAt: item.updatedAt,
|
|
356
|
+
locale: item.locale,
|
|
357
|
+
publishedAt: item.publishedAt,
|
|
342
358
|
};
|
|
343
359
|
|
|
344
360
|
if (hasSeo) {
|
|
@@ -439,12 +455,13 @@ export function createMediaAccessWithWrite(
|
|
|
439
455
|
);
|
|
440
456
|
}
|
|
441
457
|
|
|
442
|
-
|
|
458
|
+
// Generate a storage key with a unique prefix
|
|
459
|
+
const keyPrefix = ulid();
|
|
443
460
|
// Extract extension from basename (ignore path separators)
|
|
444
461
|
const basename = filename.split("/").pop() ?? filename;
|
|
445
462
|
const dotIdx = basename.lastIndexOf(".");
|
|
446
463
|
const ext = dotIdx > 0 ? basename.slice(dotIdx).toLowerCase() : "";
|
|
447
|
-
const storageKey = `${
|
|
464
|
+
const storageKey = `${keyPrefix}${ext}`;
|
|
448
465
|
|
|
449
466
|
// Upload to storage first
|
|
450
467
|
await storage.upload({
|
|
@@ -454,8 +471,9 @@ export function createMediaAccessWithWrite(
|
|
|
454
471
|
});
|
|
455
472
|
|
|
456
473
|
// Create DB record — clean up storage on failure
|
|
474
|
+
let media;
|
|
457
475
|
try {
|
|
458
|
-
await mediaRepo.create({
|
|
476
|
+
media = await mediaRepo.create({
|
|
459
477
|
filename: basename,
|
|
460
478
|
mimeType: contentType,
|
|
461
479
|
size: bytes.byteLength,
|
|
@@ -472,7 +490,7 @@ export function createMediaAccessWithWrite(
|
|
|
472
490
|
}
|
|
473
491
|
|
|
474
492
|
return {
|
|
475
|
-
mediaId,
|
|
493
|
+
mediaId: media.id,
|
|
476
494
|
storageKey,
|
|
477
495
|
url: `/_emdash/api/media/file/${storageKey}`,
|
|
478
496
|
};
|
|
@@ -491,11 +509,17 @@ export function createMediaAccessWithWrite(
|
|
|
491
509
|
/** Maximum number of redirects to follow in plugin HTTP access */
|
|
492
510
|
const MAX_PLUGIN_REDIRECTS = 5;
|
|
493
511
|
|
|
512
|
+
/**
|
|
513
|
+
* Check if a hostname matches any pattern in the allowed list.
|
|
514
|
+
* Patterns: "*" matches all, "*.example.com" matches subdomains AND bare "example.com",
|
|
515
|
+
* "api.example.com" matches exactly.
|
|
516
|
+
*/
|
|
494
517
|
function isHostAllowed(host: string, allowedHosts: string[]): boolean {
|
|
495
518
|
return allowedHosts.some((pattern) => {
|
|
496
519
|
if (pattern === "*") return true;
|
|
497
520
|
if (pattern.startsWith("*.")) {
|
|
498
521
|
const suffix = pattern.slice(1); // ".example.com"
|
|
522
|
+
// Match subdomains (foo.example.com) and bare domain (example.com)
|
|
499
523
|
return host.endsWith(suffix) || host === pattern.slice(2);
|
|
500
524
|
}
|
|
501
525
|
return host === pattern;
|