emdash 0.5.0 → 0.7.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-C2BzVy0p.d.mts → adapters-Di31kZ28.d.mts} +16 -1
- package/dist/adapters-Di31kZ28.d.mts.map +1 -0
- package/dist/{apply-Cma_PiF6.mjs → apply-5uslYdUu.mjs} +197 -25
- package/dist/apply-5uslYdUu.mjs.map +1 -0
- package/dist/astro/index.d.mts +6 -6
- package/dist/astro/index.d.mts.map +1 -1
- package/dist/astro/index.mjs +203 -33
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/middleware/auth.d.mts +5 -5
- package/dist/astro/middleware/auth.d.mts.map +1 -1
- package/dist/astro/middleware/auth.mjs +30 -4
- package/dist/astro/middleware/auth.mjs.map +1 -1
- package/dist/astro/middleware/redirect.mjs +2 -2
- package/dist/astro/middleware/request-context.d.mts.map +1 -1
- package/dist/astro/middleware/request-context.mjs +11 -4
- 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 +467 -186
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +17 -9
- package/dist/astro/types.d.mts.map +1 -1
- package/dist/{byline-WuOq9MFJ.mjs → byline-C4OVd8b3.mjs} +3 -19
- package/dist/byline-C4OVd8b3.mjs.map +1 -0
- package/dist/{bylines-C_Wsnz4L.mjs → bylines-hPTW79hw.mjs} +20 -33
- package/dist/bylines-hPTW79hw.mjs.map +1 -0
- package/dist/{cache-E3Dts-yT.mjs → cache-BkKBuIvS.mjs} +1 -1
- package/dist/{cache-E3Dts-yT.mjs.map → cache-BkKBuIvS.mjs.map} +1 -1
- package/dist/chunks-HGz06Soa.mjs +19 -0
- package/dist/chunks-HGz06Soa.mjs.map +1 -0
- package/dist/cli/index.mjs +12 -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-DkxPrM9l.mjs → config-BXwuX8Bx.mjs} +1 -1
- package/dist/{config-DkxPrM9l.mjs.map → config-BXwuX8Bx.mjs.map} +1 -1
- package/dist/{connection-B4zVnQIa.mjs → connection-2igzM-AT.mjs} +19 -2
- package/dist/connection-2igzM-AT.mjs.map +1 -0
- package/dist/{content-BsBoyj8G.mjs → content-D7J5y73J.mjs} +27 -1
- package/dist/{content-BsBoyj8G.mjs.map → content-D7J5y73J.mjs.map} +1 -1
- package/dist/database/instrumentation.d.mts +45 -0
- package/dist/database/instrumentation.d.mts.map +1 -0
- package/dist/database/instrumentation.mjs +61 -0
- package/dist/database/instrumentation.mjs.map +1 -0
- package/dist/db/index.d.mts +3 -3
- package/dist/db/index.mjs +1 -1
- package/dist/db/index.mjs.map +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/db-errors-D0UT85nC.mjs +41 -0
- package/dist/db-errors-D0UT85nC.mjs.map +1 -0
- package/dist/{default-PUx9RK6u.mjs → default-CME5YdZ3.mjs} +1 -1
- package/dist/{default-PUx9RK6u.mjs.map → default-CME5YdZ3.mjs.map} +1 -1
- package/dist/{error-HBeQbVhV.mjs → error-CiYn9yDu.mjs} +1 -1
- package/dist/{error-HBeQbVhV.mjs.map → error-CiYn9yDu.mjs.map} +1 -1
- package/dist/{index-CCWzlriB.d.mts → index-De6_Xv3v.d.mts} +209 -19
- package/dist/index-De6_Xv3v.d.mts.map +1 -0
- package/dist/index.d.mts +11 -11
- package/dist/index.mjs +23 -21
- package/dist/{load-BhSSm-TS.mjs → load-CBcmDIot.mjs} +1 -1
- package/dist/{load-BhSSm-TS.mjs.map → load-CBcmDIot.mjs.map} +1 -1
- package/dist/{loader-BYzwzORf.mjs → loader-DeiBJEMe.mjs} +18 -12
- package/dist/loader-DeiBJEMe.mjs.map +1 -0
- package/dist/{manifest-schema-BsXINkQD.mjs → manifest-schema-V30qsMft.mjs} +1 -1
- package/dist/{manifest-schema-BsXINkQD.mjs.map → manifest-schema-V30qsMft.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-CyPLdO3C.mjs → mode-CpNnGkPz.mjs} +1 -1
- package/dist/{mode-CyPLdO3C.mjs.map → mode-CpNnGkPz.mjs.map} +1 -1
- package/dist/page/index.d.mts +11 -2
- package/dist/page/index.d.mts.map +1 -1
- package/dist/page/index.mjs +23 -1
- package/dist/page/index.mjs.map +1 -1
- package/dist/{placeholder-DntBEQo7.mjs → placeholder-C-fk5hYI.mjs} +1 -1
- package/dist/{placeholder-DntBEQo7.mjs.map → placeholder-C-fk5hYI.mjs.map} +1 -1
- package/dist/{placeholder-BBCtpTES.d.mts → placeholder-tzpqGWII.d.mts} +1 -1
- package/dist/{placeholder-BBCtpTES.d.mts.map → placeholder-tzpqGWII.d.mts.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-B6Vu0d2i.mjs → query-g4Ug-9j9.mjs} +79 -12
- package/dist/query-g4Ug-9j9.mjs.map +1 -0
- package/dist/{redirect-7lGhLBNZ.mjs → redirect-CN0Rt9Ob.mjs} +66 -10
- package/dist/redirect-CN0Rt9Ob.mjs.map +1 -0
- package/dist/{registry-BgnP3ysR.mjs → registry-Ci3WxVAr.mjs} +133 -97
- package/dist/registry-Ci3WxVAr.mjs.map +1 -0
- package/dist/request-cache-DiR961CV.mjs +79 -0
- package/dist/request-cache-DiR961CV.mjs.map +1 -0
- package/dist/request-context.d.mts +19 -16
- package/dist/request-context.d.mts.map +1 -1
- package/dist/request-context.mjs.map +1 -1
- package/dist/{runner-DYv3rX8P.d.mts → runner-BR2xKwhn.d.mts} +2 -2
- package/dist/{runner-DYv3rX8P.d.mts.map → runner-BR2xKwhn.d.mts.map} +1 -1
- package/dist/{runner-Cd-_WyDo.mjs → runner-tQ7BJ4T7.mjs} +211 -134
- package/dist/runner-tQ7BJ4T7.mjs.map +1 -0
- package/dist/runtime.d.mts +6 -6
- package/dist/runtime.mjs +1 -1
- package/dist/{search-Cn1SYvYF.mjs → search-B0effn3j.mjs} +210 -226
- package/dist/search-B0effn3j.mjs.map +1 -0
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +10 -9
- 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/taxonomies-K2z0Uhnj.mjs +308 -0
- package/dist/taxonomies-K2z0Uhnj.mjs.map +1 -0
- package/dist/{tokens-DKHiCYCB.mjs → tokens-BFPFx3CA.mjs} +1 -1
- package/dist/{tokens-DKHiCYCB.mjs.map → tokens-BFPFx3CA.mjs.map} +1 -1
- package/dist/{transport-BtcQ-Z7T.mjs → transport-BykRfpyy.mjs} +1 -1
- package/dist/{transport-BtcQ-Z7T.mjs.map → transport-BykRfpyy.mjs.map} +1 -1
- package/dist/{transport-CKQA_G44.d.mts → transport-H4Iwx7tC.d.mts} +1 -1
- package/dist/{transport-CKQA_G44.d.mts.map → transport-H4Iwx7tC.d.mts.map} +1 -1
- package/dist/{types-BmkQR1En.d.mts → types-6CUZRrZP.d.mts} +1 -1
- package/dist/{types-BmkQR1En.d.mts.map → types-6CUZRrZP.d.mts.map} +1 -1
- package/dist/{types-Dz9_WMS6.mjs → types-BH2L167P.mjs} +1 -1
- package/dist/{types-Dz9_WMS6.mjs.map → types-BH2L167P.mjs.map} +1 -1
- package/dist/{types-B6BzlZxx.d.mts → types-C2v0c34j.d.mts} +10 -1
- package/dist/{types-B6BzlZxx.d.mts.map → types-C2v0c34j.d.mts.map} +1 -1
- package/dist/{types-DNZpaCBk.d.mts → types-CFWjXmus.d.mts} +1 -1
- package/dist/{types-DNZpaCBk.d.mts.map → types-CFWjXmus.d.mts.map} +1 -1
- package/dist/{types-DeG21anB.d.mts → types-CnZYHyLW.d.mts} +55 -5
- package/dist/types-CnZYHyLW.d.mts.map +1 -0
- package/dist/{types-xxCWI3j0.mjs → types-DDS4MxsT.mjs} +11 -3
- package/dist/types-DDS4MxsT.mjs.map +1 -0
- package/dist/{types-C3ronwXb.d.mts → types-DgrIP0tF.d.mts} +102 -4
- package/dist/types-DgrIP0tF.d.mts.map +1 -0
- package/dist/{validate-DuZDIxfy.mjs → validate-CqsNItbt.mjs} +2 -2
- package/dist/{validate-DuZDIxfy.mjs.map → validate-CqsNItbt.mjs.map} +1 -1
- package/dist/{validate-Db1yNL3i.d.mts → validate-kM8Pjuf7.d.mts} +5 -52
- package/dist/validate-kM8Pjuf7.d.mts.map +1 -0
- package/dist/version-BnTKdfam.mjs +7 -0
- package/dist/{version-CMMjTuqu.mjs.map → version-BnTKdfam.mjs.map} +1 -1
- package/package.json +10 -5
- package/src/after.ts +62 -0
- package/src/api/handlers/content.ts +2 -0
- package/src/api/handlers/oauth-authorization.ts +2 -32
- package/src/api/handlers/oauth-clients.ts +40 -4
- package/src/api/handlers/taxonomies.ts +13 -0
- package/src/api/oauth/redirect-uri.ts +34 -0
- package/src/api/openapi/document.ts +126 -118
- package/src/api/schemas/content.ts +8 -0
- package/src/api/schemas/media.ts +26 -15
- package/src/api/schemas/schema.ts +1 -0
- package/src/astro/integration/font-provider.ts +178 -0
- package/src/astro/integration/index.ts +44 -0
- package/src/astro/integration/routes.ts +6 -0
- package/src/astro/integration/runtime.ts +117 -0
- package/src/astro/integration/virtual-modules.ts +41 -39
- package/src/astro/integration/vite-config.ts +16 -5
- package/src/astro/middleware/auth.ts +33 -1
- package/src/astro/middleware/request-context.ts +15 -3
- package/src/astro/middleware.ts +340 -263
- package/src/astro/routes/admin.astro +21 -10
- package/src/astro/routes/api/auth/magic-link/send.ts +2 -1
- package/src/astro/routes/api/auth/passkey/options.ts +2 -1
- package/src/astro/routes/api/auth/passkey/verify.ts +5 -1
- package/src/astro/routes/api/auth/signup/request.ts +26 -8
- package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +10 -6
- package/src/astro/routes/api/content/[collection]/[id]/compare.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +5 -0
- package/src/astro/routes/api/content/[collection]/[id]/translations.ts +26 -0
- package/src/astro/routes/api/content/[collection]/[id].ts +30 -2
- package/src/astro/routes/api/content/[collection]/index.ts +19 -1
- package/src/astro/routes/api/content/[collection]/trash.ts +1 -1
- package/src/astro/routes/api/import/wordpress/execute.ts +1 -1
- package/src/astro/routes/api/import/wordpress-plugin/analyze.ts +4 -3
- package/src/astro/routes/api/import/wordpress-plugin/execute.ts +5 -4
- package/src/astro/routes/api/manifest.ts +7 -0
- package/src/astro/routes/api/media/upload-url.ts +10 -2
- package/src/astro/routes/api/media.ts +10 -7
- package/src/astro/routes/api/oauth/device/code.ts +2 -1
- package/src/astro/routes/api/oauth/device/token.ts +2 -1
- package/src/astro/routes/api/oauth/register.ts +178 -0
- package/src/astro/routes/api/oauth/token.ts +15 -0
- package/src/astro/routes/api/openapi.json.ts +15 -5
- package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +2 -0
- package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +1 -0
- package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +1 -0
- package/src/astro/routes/api/search/index.ts +5 -0
- package/src/astro/routes/api/search/suggest.ts +3 -0
- package/src/astro/routes/api/setup/admin-verify.ts +30 -5
- package/src/astro/routes/api/setup/admin.ts +32 -8
- package/src/astro/routes/api/setup/index.ts +5 -2
- package/src/astro/routes/api/taxonomies/index.ts +1 -0
- package/src/astro/routes/api/well-known/oauth-authorization-server.ts +1 -1
- package/src/astro/types.ts +9 -0
- package/src/auth/rate-limit.ts +50 -22
- package/src/auth/setup-nonce.ts +22 -0
- package/src/auth/trusted-proxy.ts +92 -0
- package/src/bylines/index.ts +22 -45
- package/src/components/EmDashHead.astro +23 -7
- package/src/database/connection.ts +23 -1
- package/src/database/instrumentation.ts +98 -0
- package/src/database/migrations/035_bounded_404_log.ts +112 -0
- package/src/database/migrations/runner.ts +2 -0
- package/src/database/repositories/content.ts +39 -0
- package/src/database/repositories/options.ts +25 -0
- package/src/database/repositories/redirect.ts +111 -8
- package/src/database/types.ts +9 -0
- package/src/db/adapters.ts +15 -0
- package/src/emdash-runtime.ts +312 -92
- package/src/import/registry.ts +4 -3
- package/src/import/ssrf.ts +253 -12
- package/src/index.ts +6 -0
- package/src/loader.ts +19 -24
- package/src/mcp/server.ts +76 -3
- package/src/menus/index.ts +6 -3
- package/src/page/index.ts +1 -1
- package/src/page/seo-contributions.ts +36 -0
- package/src/plugins/context.ts +15 -3
- package/src/plugins/manager.ts +6 -0
- package/src/plugins/request-meta.ts +66 -15
- package/src/plugins/routes.ts +3 -1
- package/src/query.ts +104 -7
- package/src/request-cache.ts +106 -0
- package/src/request-context.ts +19 -0
- package/src/schema/query.ts +5 -2
- package/src/schema/registry.ts +243 -166
- package/src/schema/types.ts +13 -2
- package/src/schema/zod-generator.ts +4 -0
- package/src/search/fts-manager.ts +19 -5
- package/src/search/query.ts +4 -3
- package/src/seed/apply.ts +41 -1
- package/src/settings/index.ts +24 -5
- package/src/taxonomies/index.ts +324 -124
- package/src/utils/db-errors.ts +46 -0
- package/src/virtual-modules.d.ts +31 -10
- package/src/visual-editing/toolbar.ts +6 -1
- package/src/widgets/index.ts +54 -25
- package/dist/adapters-C2BzVy0p.d.mts.map +0 -1
- package/dist/apply-Cma_PiF6.mjs.map +0 -1
- package/dist/byline-WuOq9MFJ.mjs.map +0 -1
- package/dist/bylines-C_Wsnz4L.mjs.map +0 -1
- package/dist/connection-B4zVnQIa.mjs.map +0 -1
- package/dist/index-CCWzlriB.d.mts.map +0 -1
- package/dist/loader-BYzwzORf.mjs.map +0 -1
- package/dist/query-B6Vu0d2i.mjs.map +0 -1
- package/dist/redirect-7lGhLBNZ.mjs.map +0 -1
- package/dist/registry-BgnP3ysR.mjs.map +0 -1
- package/dist/runner-Cd-_WyDo.mjs.map +0 -1
- package/dist/search-Cn1SYvYF.mjs.map +0 -1
- package/dist/types-C3ronwXb.d.mts.map +0 -1
- package/dist/types-DeG21anB.d.mts.map +0 -1
- package/dist/types-xxCWI3j0.mjs.map +0 -1
- package/dist/validate-Db1yNL3i.d.mts.map +0 -1
- package/dist/version-CMMjTuqu.mjs +0 -7
package/src/plugins/context.ts
CHANGED
|
@@ -16,7 +16,11 @@ import { SeoRepository } from "../database/repositories/seo.js";
|
|
|
16
16
|
import { UserRepository } from "../database/repositories/user.js";
|
|
17
17
|
import { withTransaction } from "../database/transaction.js";
|
|
18
18
|
import type { Database } from "../database/types.js";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
resolveAndValidateExternalUrl,
|
|
21
|
+
SsrfError,
|
|
22
|
+
stripCredentialHeaders,
|
|
23
|
+
} from "../import/ssrf.js";
|
|
20
24
|
import type { Storage } from "../storage/types.js";
|
|
21
25
|
import { CronAccessImpl } from "./cron.js";
|
|
22
26
|
import type { EmailPipeline } from "./email.js";
|
|
@@ -599,9 +603,10 @@ export function createUnrestrictedHttpAccess(pluginId: string): HttpAccess {
|
|
|
599
603
|
let currentInit = init;
|
|
600
604
|
|
|
601
605
|
for (let i = 0; i <= MAX_PLUGIN_REDIRECTS; i++) {
|
|
602
|
-
// Validate each URL against SSRF rules (private IPs, metadata
|
|
606
|
+
// Validate each URL against SSRF rules (private IPs, metadata
|
|
607
|
+
// endpoints, wildcard DNS, resolved-IP private ranges).
|
|
603
608
|
try {
|
|
604
|
-
|
|
609
|
+
await resolveAndValidateExternalUrl(currentUrl);
|
|
605
610
|
} catch (e) {
|
|
606
611
|
const msg = e instanceof SsrfError ? e.message : "SSRF validation failed";
|
|
607
612
|
throw new Error(
|
|
@@ -849,6 +854,13 @@ export interface PluginContextFactoryOptions {
|
|
|
849
854
|
* If not provided (or no provider configured), ctx.email will be undefined.
|
|
850
855
|
*/
|
|
851
856
|
emailPipeline?: EmailPipeline;
|
|
857
|
+
/**
|
|
858
|
+
* Pre-resolved list of trusted proxy header names (from the runtime
|
|
859
|
+
* `EmDashConfig.trustedProxyHeaders` or the env var). Plugin route
|
|
860
|
+
* handlers pass this to `extractRequestMeta` so plugins see the same
|
|
861
|
+
* client IP the core auth path does.
|
|
862
|
+
*/
|
|
863
|
+
trustedProxyHeaders?: string[];
|
|
852
864
|
}
|
|
853
865
|
|
|
854
866
|
/**
|
package/src/plugins/manager.ts
CHANGED
|
@@ -62,6 +62,11 @@ export interface PluginManagerOptions {
|
|
|
62
62
|
filename: string,
|
|
63
63
|
contentType: string,
|
|
64
64
|
) => Promise<{ uploadUrl: string; mediaId: string }>;
|
|
65
|
+
/**
|
|
66
|
+
* Pre-resolved list of trusted proxy header names for client-IP
|
|
67
|
+
* resolution in plugin route handlers. Thread through from the runtime.
|
|
68
|
+
*/
|
|
69
|
+
trustedProxyHeaders?: string[];
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
/**
|
|
@@ -81,6 +86,7 @@ export class PluginManager {
|
|
|
81
86
|
db: options.db,
|
|
82
87
|
storage: options.storage,
|
|
83
88
|
getUploadUrl: options.getUploadUrl,
|
|
89
|
+
trustedProxyHeaders: options.trustedProxyHeaders,
|
|
84
90
|
};
|
|
85
91
|
}
|
|
86
92
|
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type { EmDashConfig } from "../astro/integration/runtime.js";
|
|
11
|
+
import { getTrustedProxyHeaders, normalizeTrustedHeaders } from "../auth/trusted-proxy.js";
|
|
10
12
|
import type { GeoInfo, RequestMeta } from "./types.js";
|
|
11
13
|
|
|
12
14
|
/**
|
|
@@ -40,6 +42,23 @@ function parseFirstForwardedIp(header: string): string | null {
|
|
|
40
42
|
return IP_PATTERN.test(trimmed) ? trimmed : null;
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Read an IP from an operator-declared trusted header. XFF-style headers
|
|
47
|
+
* (any name ending in `forwarded-for`) are parsed as comma-separated lists
|
|
48
|
+
* and the first entry is used; everything else is treated as a single
|
|
49
|
+
* trimmed value.
|
|
50
|
+
*/
|
|
51
|
+
function readIpFromHeader(headers: Headers, name: string): string | null {
|
|
52
|
+
const value = headers.get(name);
|
|
53
|
+
if (!value) return null;
|
|
54
|
+
if (name.endsWith("forwarded-for")) {
|
|
55
|
+
return parseFirstForwardedIp(value);
|
|
56
|
+
}
|
|
57
|
+
const trimmed = value.trim();
|
|
58
|
+
if (!trimmed) return null;
|
|
59
|
+
return IP_PATTERN.test(trimmed) ? trimmed : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
/**
|
|
44
63
|
* Get the Cloudflare `cf` object from the request, if present.
|
|
45
64
|
* Returns undefined when not running on Cloudflare Workers.
|
|
@@ -69,32 +88,52 @@ function extractGeo(cf: CfProperties | undefined): GeoInfo | null {
|
|
|
69
88
|
* Extract normalized request metadata from a Request object.
|
|
70
89
|
*
|
|
71
90
|
* IP resolution order:
|
|
72
|
-
* 1. `CF-Connecting-IP`
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* 3. `
|
|
91
|
+
* 1. `CF-Connecting-IP` — trusted only when a `cf` object is present on the
|
|
92
|
+
* request. CF edge overwrites any client-supplied value, so this is the
|
|
93
|
+
* cryptographically trustworthy path on Workers. Operator-declared
|
|
94
|
+
* trusted headers cannot override it.
|
|
95
|
+
* 2. `X-Forwarded-For` first entry — trusted only with a `cf` object.
|
|
96
|
+
* 3. Operator-declared trusted proxy headers (from `config.trustedProxyHeaders`
|
|
97
|
+
* or the `EMDASH_TRUSTED_PROXY_HEADERS` env var), tried in order. Used as
|
|
98
|
+
* the primary source off-CF and as a fill-in on CF.
|
|
99
|
+
* 4. `null`
|
|
100
|
+
*
|
|
101
|
+
* The second argument accepts either the EmDash config or a pre-resolved
|
|
102
|
+
* list of trusted headers, so callers that already have the list don't have
|
|
103
|
+
* to round-trip through the config every request.
|
|
78
104
|
*/
|
|
79
|
-
export function extractRequestMeta(
|
|
105
|
+
export function extractRequestMeta(
|
|
106
|
+
request: Request,
|
|
107
|
+
configOrTrustedHeaders?: EmDashConfig | null | { trustedProxyHeaders?: string[] } | string[],
|
|
108
|
+
): RequestMeta {
|
|
80
109
|
const headers = request.headers;
|
|
81
110
|
const cf = getCfObject(request);
|
|
111
|
+
const trusted = resolveTrustedHeaders(configOrTrustedHeaders);
|
|
82
112
|
|
|
83
|
-
// IP: only trust headers when the cf object confirms we're on Cloudflare.
|
|
84
|
-
// Without a trusted reverse proxy, X-Forwarded-For is trivially spoofable.
|
|
85
113
|
let ip: string | null = null;
|
|
114
|
+
|
|
115
|
+
// On Cloudflare, prefer the cryptographically trustworthy headers first.
|
|
86
116
|
if (cf) {
|
|
87
117
|
const cfIp = headers.get("cf-connecting-ip")?.trim();
|
|
88
118
|
if (cfIp && IP_PATTERN.test(cfIp)) {
|
|
89
119
|
ip = cfIp;
|
|
90
120
|
}
|
|
121
|
+
if (!ip) {
|
|
122
|
+
const xff = headers.get("x-forwarded-for");
|
|
123
|
+
ip = xff ? parseFirstForwardedIp(xff) : null;
|
|
124
|
+
}
|
|
91
125
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
126
|
+
|
|
127
|
+
// Fall through to operator-declared trusted headers. On CF this fills
|
|
128
|
+
// in when the CF headers are absent; off-CF it's the primary source.
|
|
129
|
+
if (!ip) {
|
|
130
|
+
for (const name of trusted) {
|
|
131
|
+
const value = readIpFromHeader(headers, name);
|
|
132
|
+
if (value) {
|
|
133
|
+
ip = value;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
98
137
|
}
|
|
99
138
|
|
|
100
139
|
const userAgent = headers.get("user-agent")?.trim() || null;
|
|
@@ -104,6 +143,18 @@ export function extractRequestMeta(request: Request): RequestMeta {
|
|
|
104
143
|
return { ip, userAgent, referer, geo };
|
|
105
144
|
}
|
|
106
145
|
|
|
146
|
+
function resolveTrustedHeaders(
|
|
147
|
+
value: EmDashConfig | null | { trustedProxyHeaders?: string[] } | string[] | undefined,
|
|
148
|
+
): string[] {
|
|
149
|
+
if (Array.isArray(value)) {
|
|
150
|
+
// Apply the same RFC 7230 validation the config/env path does so a
|
|
151
|
+
// caller passing a pre-resolved list with bad entries can't crash
|
|
152
|
+
// `Headers.get()` downstream.
|
|
153
|
+
return normalizeTrustedHeaders(value);
|
|
154
|
+
}
|
|
155
|
+
return getTrustedProxyHeaders(value);
|
|
156
|
+
}
|
|
157
|
+
|
|
107
158
|
// =============================================================================
|
|
108
159
|
// Header Sanitization for Sandbox
|
|
109
160
|
// =============================================================================
|
package/src/plugins/routes.ts
CHANGED
|
@@ -50,10 +50,12 @@ export interface InvokeRouteOptions {
|
|
|
50
50
|
export class PluginRouteHandler {
|
|
51
51
|
private contextFactory: PluginContextFactory;
|
|
52
52
|
private plugin: ResolvedPlugin;
|
|
53
|
+
private trustedProxyHeaders: string[];
|
|
53
54
|
|
|
54
55
|
constructor(plugin: ResolvedPlugin, factoryOptions: PluginContextFactoryOptions) {
|
|
55
56
|
this.plugin = plugin;
|
|
56
57
|
this.contextFactory = new PluginContextFactory(factoryOptions);
|
|
58
|
+
this.trustedProxyHeaders = factoryOptions.trustedProxyHeaders ?? [];
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
/**
|
|
@@ -99,7 +101,7 @@ export class PluginRouteHandler {
|
|
|
99
101
|
...baseContext,
|
|
100
102
|
input: validatedInput,
|
|
101
103
|
request: options.request,
|
|
102
|
-
requestMeta: extractRequestMeta(options.request),
|
|
104
|
+
requestMeta: extractRequestMeta(options.request, this.trustedProxyHeaders),
|
|
103
105
|
};
|
|
104
106
|
|
|
105
107
|
// Execute handler
|
package/src/query.ts
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { getFallbackChain, getI18nConfig, isI18nEnabled } from "./i18n/config.js";
|
|
16
|
+
import { requestCached } from "./request-cache.js";
|
|
16
17
|
import { getRequestContext } from "./request-context.js";
|
|
18
|
+
import { isMissingTableError } from "./utils/db-errors.js";
|
|
17
19
|
import {
|
|
18
20
|
createEditable,
|
|
19
21
|
createNoop,
|
|
@@ -269,6 +271,51 @@ function entryEditOptions(entry: { data?: unknown }): EditableOptions {
|
|
|
269
271
|
export async function getEmDashCollection<T extends string, D = InferCollectionData<T>>(
|
|
270
272
|
type: T,
|
|
271
273
|
filter?: CollectionFilter,
|
|
274
|
+
): Promise<CollectionResult<D>> {
|
|
275
|
+
// Cache per (type, filter) within a single request. Edit mode and
|
|
276
|
+
// preview are request-scoped and stable, so they don't need to be
|
|
277
|
+
// part of the key. Widgets and layouts frequently request the same
|
|
278
|
+
// collection shape as the page itself (e.g. a "recent posts" list
|
|
279
|
+
// appears on the home page AND in the sidebar) — caching collapses
|
|
280
|
+
// those duplicate queries, along with the bylines and taxonomy-term
|
|
281
|
+
// hydration each call would otherwise re-do.
|
|
282
|
+
return requestCached(collectionCacheKey(type, filter), () =>
|
|
283
|
+
getEmDashCollectionUncached<T, D>(type, filter),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Build a canonical cache key for `getEmDashCollection`.
|
|
289
|
+
*
|
|
290
|
+
* `JSON.stringify` is insertion-order-sensitive, so two callers passing
|
|
291
|
+
* semantically identical filters with different key orders would miss
|
|
292
|
+
* the cache. We fix the top-level field order and sort `where` keys
|
|
293
|
+
* (order there is irrelevant), while preserving `orderBy` key order
|
|
294
|
+
* because that's the sort priority.
|
|
295
|
+
*/
|
|
296
|
+
function collectionCacheKey(type: string, filter?: CollectionFilter): string {
|
|
297
|
+
if (!filter) return `collection:${type}:`;
|
|
298
|
+
const parts = [
|
|
299
|
+
filter.status ?? "",
|
|
300
|
+
filter.limit ?? "",
|
|
301
|
+
filter.cursor ?? "",
|
|
302
|
+
filter.where ? stableStringify(filter.where) : "",
|
|
303
|
+
filter.orderBy ? JSON.stringify(filter.orderBy) : "",
|
|
304
|
+
filter.locale ?? "",
|
|
305
|
+
];
|
|
306
|
+
return `collection:${type}:${parts.join("|")}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function stableStringify(value: Record<string, unknown>): string {
|
|
310
|
+
const keys = Object.keys(value).toSorted();
|
|
311
|
+
const ordered: Record<string, unknown> = {};
|
|
312
|
+
for (const k of keys) ordered[k] = value[k];
|
|
313
|
+
return JSON.stringify(ordered);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function getEmDashCollectionUncached<T extends string, D = InferCollectionData<T>>(
|
|
317
|
+
type: T,
|
|
318
|
+
filter?: CollectionFilter,
|
|
272
319
|
): Promise<CollectionResult<D>> {
|
|
273
320
|
// Dynamic import to avoid build-time issues
|
|
274
321
|
const { getLiveCollection } = await import("astro:content");
|
|
@@ -313,8 +360,13 @@ export async function getEmDashCollection<T extends string, D = InferCollectionD
|
|
|
313
360
|
};
|
|
314
361
|
});
|
|
315
362
|
|
|
316
|
-
// Eagerly hydrate bylines for all entries
|
|
317
|
-
|
|
363
|
+
// Eagerly hydrate bylines and taxonomy terms for all entries in parallel.
|
|
364
|
+
// Both are independent queries, so running them concurrently halves the
|
|
365
|
+
// round-trip cost on remote databases (D1 replicas, etc.).
|
|
366
|
+
await Promise.all([
|
|
367
|
+
hydrateEntryBylines(type, entriesWithEdit),
|
|
368
|
+
hydrateEntryTerms(type, entriesWithEdit),
|
|
369
|
+
]);
|
|
318
370
|
|
|
319
371
|
return { entries: entriesWithEdit, nextCursor, cacheHint: cacheHint ?? {} };
|
|
320
372
|
}
|
|
@@ -386,12 +438,12 @@ export async function getEmDashEntry<T extends string, D = InferCollectionData<T
|
|
|
386
438
|
const localeChain =
|
|
387
439
|
requestedLocale && isI18nEnabled() ? getFallbackChain(requestedLocale) : [requestedLocale];
|
|
388
440
|
|
|
389
|
-
/** Return a successful EntryResult with bylines hydrated */
|
|
441
|
+
/** Return a successful EntryResult with bylines and taxonomy terms hydrated */
|
|
390
442
|
async function successResult(
|
|
391
443
|
wrapped: ContentEntry<D>,
|
|
392
444
|
opts: { isPreview: boolean; fallbackLocale?: string; cacheHint: CacheHint },
|
|
393
445
|
): Promise<EntryResult<D>> {
|
|
394
|
-
await hydrateEntryBylines(type, [wrapped]);
|
|
446
|
+
await Promise.all([hydrateEntryBylines(type, [wrapped]), hydrateEntryTerms(type, [wrapped])]);
|
|
395
447
|
return {
|
|
396
448
|
entry: wrapped,
|
|
397
449
|
isPreview: opts.isPreview,
|
|
@@ -525,14 +577,59 @@ async function hydrateEntryBylines<D>(type: string, entries: ContentEntry<D>[]):
|
|
|
525
577
|
data.byline = credits[0]?.byline ?? null;
|
|
526
578
|
}
|
|
527
579
|
} catch (err) {
|
|
528
|
-
// Only swallow "table not found" errors from pre-migration databases
|
|
529
|
-
|
|
530
|
-
|
|
580
|
+
// Only swallow "table not found" errors from pre-migration databases.
|
|
581
|
+
// Matches SQLite/D1 ("no such table") and PostgreSQL ("relation/table
|
|
582
|
+
// ... does not exist") via the shared helper.
|
|
583
|
+
if (!isMissingTableError(err)) {
|
|
584
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
531
585
|
console.warn("[emdash] Failed to hydrate bylines:", msg);
|
|
532
586
|
}
|
|
533
587
|
}
|
|
534
588
|
}
|
|
535
589
|
|
|
590
|
+
/**
|
|
591
|
+
* Eagerly hydrate taxonomy term data onto entry.data for one or more entries.
|
|
592
|
+
*
|
|
593
|
+
* Attaches `terms` (Record keyed by taxonomy name with an array of TaxonomyTerm
|
|
594
|
+
* values) to each entry's data object. Uses a single batched JOIN query across
|
|
595
|
+
* all taxonomies so the cost is O(1) regardless of the number of entries or
|
|
596
|
+
* taxonomies on the site.
|
|
597
|
+
*
|
|
598
|
+
* This eliminates the common N+1 pattern where templates loop over list
|
|
599
|
+
* results and call getEntryTerms() per entry. With hydration, the list page
|
|
600
|
+
* stays at a single round-trip for term data.
|
|
601
|
+
*
|
|
602
|
+
* Fails silently if the taxonomy tables don't exist yet (pre-migration).
|
|
603
|
+
*/
|
|
604
|
+
async function hydrateEntryTerms<D>(type: string, entries: ContentEntry<D>[]): Promise<void> {
|
|
605
|
+
if (entries.length === 0) return;
|
|
606
|
+
|
|
607
|
+
try {
|
|
608
|
+
const { getAllTermsForEntries } = await import("./taxonomies/index.js");
|
|
609
|
+
|
|
610
|
+
const ids = entries.map((e) => dataStr(entryData(e), "id")).filter(Boolean);
|
|
611
|
+
if (ids.length === 0) return;
|
|
612
|
+
|
|
613
|
+
const termsMap = await getAllTermsForEntries(type, ids);
|
|
614
|
+
|
|
615
|
+
for (const entry of entries) {
|
|
616
|
+
const data = entryData(entry);
|
|
617
|
+
const dbId = dataStr(data, "id");
|
|
618
|
+
if (!dbId) continue;
|
|
619
|
+
|
|
620
|
+
data.terms = termsMap.get(dbId) ?? {};
|
|
621
|
+
}
|
|
622
|
+
} catch (err) {
|
|
623
|
+
// Only swallow "table not found" errors from pre-migration databases.
|
|
624
|
+
// Matches SQLite/D1 ("no such table") and PostgreSQL ("relation/table
|
|
625
|
+
// ... does not exist") via the shared helper.
|
|
626
|
+
if (!isMissingTableError(err)) {
|
|
627
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
628
|
+
console.warn("[emdash] Failed to hydrate terms:", msg);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
536
633
|
/**
|
|
537
634
|
* Translation summary for a single locale variant
|
|
538
635
|
*/
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request query cache
|
|
3
|
+
*
|
|
4
|
+
* Deduplicates identical database queries within a single page render.
|
|
5
|
+
* Uses the ALS request context as a WeakMap key so the cache is
|
|
6
|
+
* automatically GC'd when the request completes.
|
|
7
|
+
*
|
|
8
|
+
* When no request context is available (e.g. local dev without D1
|
|
9
|
+
* replicas), queries bypass the cache — local SQLite is fast enough
|
|
10
|
+
* that deduplication doesn't matter.
|
|
11
|
+
*
|
|
12
|
+
* The WeakMap is stored on globalThis with a Symbol key to guarantee
|
|
13
|
+
* a singleton even when bundlers duplicate this module across chunks
|
|
14
|
+
* (same pattern as request-context.ts).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { EmDashRequestContext } from "./request-context.js";
|
|
18
|
+
import { getRequestContext } from "./request-context.js";
|
|
19
|
+
|
|
20
|
+
type CacheStore = WeakMap<EmDashRequestContext, Map<string, Promise<unknown>>>;
|
|
21
|
+
|
|
22
|
+
const STORE_KEY = Symbol.for("emdash:request-cache");
|
|
23
|
+
const g = globalThis as Record<symbol, unknown>;
|
|
24
|
+
const store: CacheStore =
|
|
25
|
+
(g[STORE_KEY] as CacheStore | undefined) ??
|
|
26
|
+
(() => {
|
|
27
|
+
const wm: CacheStore = new WeakMap();
|
|
28
|
+
g[STORE_KEY] = wm;
|
|
29
|
+
return wm;
|
|
30
|
+
})();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Return a cached result for `key` if one exists in the current
|
|
34
|
+
* request scope, otherwise call `fn`, cache its promise, and return it.
|
|
35
|
+
*
|
|
36
|
+
* Caches the *promise*, not the resolved value, so concurrent calls
|
|
37
|
+
* with the same key share a single in-flight query.
|
|
38
|
+
*/
|
|
39
|
+
export function requestCached<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
40
|
+
const ctx = getRequestContext();
|
|
41
|
+
if (!ctx) return fn();
|
|
42
|
+
|
|
43
|
+
let cache = store.get(ctx);
|
|
44
|
+
if (!cache) {
|
|
45
|
+
cache = new Map();
|
|
46
|
+
store.set(ctx, cache);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const existing = cache.get(key);
|
|
50
|
+
if (existing) return existing as Promise<T>;
|
|
51
|
+
|
|
52
|
+
const promise = Promise.resolve()
|
|
53
|
+
.then(fn)
|
|
54
|
+
.catch((error) => {
|
|
55
|
+
cache.delete(key);
|
|
56
|
+
throw error;
|
|
57
|
+
});
|
|
58
|
+
cache.set(key, promise);
|
|
59
|
+
return promise;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Look up an entry in the request-scoped cache without inserting one.
|
|
64
|
+
*
|
|
65
|
+
* Returns the in-flight or resolved promise if the key exists in the
|
|
66
|
+
* current request, otherwise `undefined`. Callers can use this to
|
|
67
|
+
* opportunistically satisfy a narrower query (e.g. `getSiteSetting("seo")`)
|
|
68
|
+
* from a broader one (`getSiteSettings()`) that's already been loaded
|
|
69
|
+
* by a parent template — avoiding a redundant round-trip.
|
|
70
|
+
*
|
|
71
|
+
* No-ops outside a request context.
|
|
72
|
+
*/
|
|
73
|
+
export function peekRequestCache<T>(key: string): Promise<T> | undefined {
|
|
74
|
+
const ctx = getRequestContext();
|
|
75
|
+
if (!ctx) return undefined;
|
|
76
|
+
const cache = store.get(ctx);
|
|
77
|
+
return cache?.get(key) as Promise<T> | undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Pre-populate the request-scoped cache with a resolved value.
|
|
82
|
+
*
|
|
83
|
+
* Internal helper shared between hydration paths (taxonomy terms,
|
|
84
|
+
* bylines, etc.) that already have the data in hand and want downstream
|
|
85
|
+
* callers using `requestCached(key, ...)` to skip the database entirely.
|
|
86
|
+
* Not exported from the package entrypoint — keep it internal until we
|
|
87
|
+
* have a documented plugin/extension surface for hydration.
|
|
88
|
+
*
|
|
89
|
+
* No-ops outside a request context (local dev without ALS).
|
|
90
|
+
*
|
|
91
|
+
* Does not overwrite an existing entry — if a query for this key is already
|
|
92
|
+
* in flight, its promise wins.
|
|
93
|
+
*/
|
|
94
|
+
export function setRequestCacheEntry<T>(key: string, value: T): void {
|
|
95
|
+
const ctx = getRequestContext();
|
|
96
|
+
if (!ctx) return;
|
|
97
|
+
|
|
98
|
+
let cache = store.get(ctx);
|
|
99
|
+
if (!cache) {
|
|
100
|
+
cache = new Map();
|
|
101
|
+
store.set(ctx, cache);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (cache.has(key)) return;
|
|
105
|
+
cache.set(key, Promise.resolve(value));
|
|
106
|
+
}
|
package/src/request-context.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
|
|
18
18
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
19
19
|
|
|
20
|
+
import type { QueryRecorder } from "./database/instrumentation.js";
|
|
21
|
+
|
|
20
22
|
export interface EmDashRequestContext {
|
|
21
23
|
/** Whether the current request is in visual editing mode */
|
|
22
24
|
editMode: boolean;
|
|
@@ -35,6 +37,23 @@ export interface EmDashRequestContext {
|
|
|
35
37
|
* the singleton instance. Also used by the DO preview pattern.
|
|
36
38
|
*/
|
|
37
39
|
db?: unknown;
|
|
40
|
+
/**
|
|
41
|
+
* Indicates the per-request `db` points at an isolated database
|
|
42
|
+
* instance whose schema may diverge from the configured one
|
|
43
|
+
* (playground, DO preview sessions). When true, schema-derived caches
|
|
44
|
+
* (manifest, taxonomy defs, etc.) must not be reused across requests.
|
|
45
|
+
*
|
|
46
|
+
* Plain D1 Sessions API routing does NOT set this — sessions are just
|
|
47
|
+
* a routing hint over the same schema, so the module-scoped manifest
|
|
48
|
+
* cache remains valid.
|
|
49
|
+
*/
|
|
50
|
+
dbIsIsolated?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Query recorder attached by middleware when EMDASH_QUERY_LOG_FILE is set.
|
|
53
|
+
* The Kysely `log` hook appends an event per query; middleware flushes
|
|
54
|
+
* to NDJSON after the response.
|
|
55
|
+
*/
|
|
56
|
+
queryRecorder?: QueryRecorder;
|
|
38
57
|
}
|
|
39
58
|
|
|
40
59
|
const ALS_KEY = Symbol.for("emdash:request-context");
|
package/src/schema/query.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { Kysely } from "kysely";
|
|
|
8
8
|
|
|
9
9
|
import type { Database } from "../database/types.js";
|
|
10
10
|
import { getDb } from "../loader.js";
|
|
11
|
+
import { requestCached } from "../request-cache.js";
|
|
11
12
|
import { SchemaRegistry } from "./registry.js";
|
|
12
13
|
import type { Collection } from "./types.js";
|
|
13
14
|
|
|
@@ -25,8 +26,10 @@ import type { Collection } from "./types.js";
|
|
|
25
26
|
* ```
|
|
26
27
|
*/
|
|
27
28
|
export async function getCollectionInfo(slug: string): Promise<Collection | null> {
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
return requestCached(`collection-info:${slug}`, async () => {
|
|
30
|
+
const db = await getDb();
|
|
31
|
+
return getCollectionInfoWithDb(db, slug);
|
|
32
|
+
});
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
/**
|