emdash 0.2.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-N6BF7RCD.d.mts → adapters-C2BzVy0p.d.mts} +1 -1
- package/dist/{adapters-N6BF7RCD.d.mts.map → adapters-C2BzVy0p.d.mts.map} +1 -1
- package/dist/{apply-wmVEOSbR.mjs → apply-Cma_PiF6.mjs} +38 -23
- package/dist/apply-Cma_PiF6.mjs.map +1 -0
- package/dist/astro/index.d.mts +25 -11
- package/dist/astro/index.d.mts.map +1 -1
- package/dist/astro/index.mjs +38 -25
- 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 +20 -8
- 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 +52 -45
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +9 -9
- package/dist/astro/types.d.mts.map +1 -1
- package/dist/{byline-1WQPlISL.mjs → byline-WuOq9MFJ.mjs} +5 -4
- package/dist/byline-WuOq9MFJ.mjs.map +1 -0
- package/dist/{bylines-BYdTYmia.mjs → bylines-C_Wsnz4L.mjs} +38 -6
- 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 +13 -13
- 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/{content-BmXndhdi.mjs → content-BsBoyj8G.mjs} +20 -3
- package/dist/content-BsBoyj8G.mjs.map +1 -0
- package/dist/db/index.d.mts +3 -3
- package/dist/db/index.mjs +2 -2
- 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/{dialect-helpers-B9uSp2GJ.mjs → dialect-helpers-DhTzaUxP.mjs} +4 -1
- package/dist/dialect-helpers-DhTzaUxP.mjs.map +1 -0
- 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-UHEVQMus.d.mts → index-CRg3PWfZ.d.mts} +59 -33
- package/dist/index-CRg3PWfZ.d.mts.map +1 -0
- package/dist/index.d.mts +11 -11
- package/dist/index.mjs +20 -20
- 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-CHb2v0jm.mjs → loader-BYzwzORf.mjs} +4 -2
- package/dist/loader-BYzwzORf.mjs.map +1 -0
- 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-CYeM2rPt.mjs → mode-CyPLdO3C.mjs} +1 -1
- package/dist/{mode-CYeM2rPt.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-bOx1xCTY.d.mts → placeholder-BBCtpTES.d.mts} +1 -1
- package/dist/{placeholder-bOx1xCTY.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-5Hcv_5ER.mjs → query-B6Vu0d2i.mjs} +35 -16
- package/dist/{query-5Hcv_5ER.mjs.map → query-B6Vu0d2i.mjs.map} +1 -1
- package/dist/{redirect-DIfIni3r.mjs → redirect-7lGhLBNZ.mjs} +10 -93
- package/dist/redirect-7lGhLBNZ.mjs.map +1 -0
- package/dist/{registry-1EvbAfsC.mjs → registry-BgnP3ysR.mjs} +27 -37
- package/dist/registry-BgnP3ysR.mjs.map +1 -0
- package/dist/{runner-BoN0-FPi.mjs → runner-Cd-_WyDo.mjs} +18 -6
- package/dist/runner-Cd-_WyDo.mjs.map +1 -0
- package/dist/{runner-DTqkzOzc.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 +2 -2
- package/dist/{search-BsYMed12.mjs → search-B5p9D36n.mjs} +108 -57
- package/dist/search-B5p9D36n.mjs.map +1 -0
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +10 -10
- 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 +11 -3
- package/dist/storage/s3.d.mts.map +1 -1
- package/dist/storage/s3.mjs +76 -15
- package/dist/storage/s3.mjs.map +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-Bl8cTdYt.mjs → transport-BtcQ-Z7T.mjs} +1 -1
- package/dist/{transport-Bl8cTdYt.mjs.map → transport-BtcQ-Z7T.mjs.map} +1 -1
- package/dist/{transport-COOs9GSE.d.mts → transport-CKQA_G44.d.mts} +1 -1
- package/dist/{transport-COOs9GSE.d.mts.map → transport-CKQA_G44.d.mts.map} +1 -1
- package/dist/{types-7-UjSEyB.d.mts → types-B6BzlZxx.d.mts} +1 -1
- package/dist/{types-7-UjSEyB.d.mts.map → types-B6BzlZxx.d.mts.map} +1 -1
- package/dist/{types-6dqxBqsH.d.mts → types-BYWYxLcp.d.mts} +109 -5
- package/dist/types-BYWYxLcp.d.mts.map +1 -0
- package/dist/{types-CIsTnQvJ.d.mts → types-BmkQR1En.d.mts} +1 -1
- package/dist/{types-CIsTnQvJ.d.mts.map → types-BmkQR1En.d.mts.map} +1 -1
- package/dist/{types-BljtYPSd.d.mts → types-DNZpaCBk.d.mts} +14 -6
- package/dist/types-DNZpaCBk.d.mts.map +1 -0
- package/dist/{types-Bec-r_3_.mjs → types-Dz9_WMS6.mjs} +1 -1
- package/dist/types-Dz9_WMS6.mjs.map +1 -0
- package/dist/{types-CcreFIIH.d.mts → types-gLYVCXCQ.d.mts} +1 -1
- package/dist/{types-CcreFIIH.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-B7KP7VLM.d.mts → validate-CcNRWH6I.d.mts} +4 -4
- package/dist/{validate-B7KP7VLM.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/{validate-CqRJb_xU.mjs → validate-VPnKoIzW.mjs} +11 -11
- package/dist/{validate-CqRJb_xU.mjs.map → validate-VPnKoIzW.mjs.map} +1 -1
- package/dist/version-DlTDRdpv.mjs +7 -0
- package/dist/version-DlTDRdpv.mjs.map +1 -0
- package/package.json +7 -5
- package/src/api/handlers/content.ts +36 -25
- package/src/api/handlers/menus.ts +19 -16
- package/src/api/handlers/redirects.ts +95 -3
- package/src/api/schemas/redirects.ts +1 -0
- package/src/astro/integration/index.ts +2 -3
- package/src/astro/integration/runtime.ts +8 -14
- package/src/astro/integration/vite-config.ts +14 -4
- package/src/astro/middleware/redirect.ts +30 -15
- package/src/astro/middleware.ts +11 -19
- package/src/astro/routes/admin.astro +2 -2
- 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/comments/[collection]/[contentId]/index.ts +2 -0
- package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +2 -0
- package/src/astro/routes/api/manifest.ts +3 -1
- 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/astro/storage/adapters.ts +19 -5
- package/src/astro/storage/types.ts +12 -4
- package/src/astro/types.ts +1 -0
- package/src/bylines/index.ts +50 -2
- package/src/cleanup.ts +3 -3
- package/src/cli/commands/bundle-utils.ts +5 -5
- package/src/database/dialect-helpers.ts +3 -0
- 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/database/repositories/content.ts +5 -0
- package/src/database/repositories/redirect.ts +13 -0
- package/src/database/validate.ts +10 -10
- package/src/emdash-runtime.ts +23 -9
- package/src/index.ts +3 -0
- package/src/loader.ts +2 -0
- package/src/mcp/server.ts +40 -67
- package/src/menus/index.ts +4 -0
- 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/redirects/loops.ts +318 -0
- package/src/schema/registry.ts +3 -0
- package/src/search/fts-manager.ts +24 -11
- package/src/search/query.ts +8 -9
- package/src/seed/apply.ts +49 -28
- package/src/storage/s3.ts +94 -25
- package/src/storage/types.ts +13 -5
- package/src/utils/slugify.ts +11 -0
- package/src/version.ts +12 -0
- package/src/visual-editing/toolbar.ts +11 -1
- package/dist/apply-wmVEOSbR.mjs.map +0 -1
- package/dist/byline-1WQPlISL.mjs.map +0 -1
- package/dist/bylines-BYdTYmia.mjs.map +0 -1
- package/dist/content-BmXndhdi.mjs.map +0 -1
- package/dist/dialect-helpers-B9uSp2GJ.mjs.map +0 -1
- package/dist/index-UHEVQMus.d.mts.map +0 -1
- package/dist/loader-CHb2v0jm.mjs.map +0 -1
- package/dist/redirect-DIfIni3r.mjs.map +0 -1
- package/dist/registry-1EvbAfsC.mjs.map +0 -1
- package/dist/runner-BoN0-FPi.mjs.map +0 -1
- package/dist/runner-DTqkzOzc.d.mts.map +0 -1
- package/dist/search-BsYMed12.mjs.map +0 -1
- package/dist/types-6dqxBqsH.d.mts.map +0 -1
- package/dist/types-Bec-r_3_.mjs.map +0 -1
- package/dist/types-BljtYPSd.d.mts.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validate-
|
|
1
|
+
{"version":3,"file":"validate-VPnKoIzW.mjs","names":[],"sources":["../src/database/validate.ts"],"sourcesContent":["/**\n * SQL Identifier Validation\n *\n * Validates identifiers (table names, column names, index names) before\n * they are used in raw SQL expressions. This is the primary defense against\n * SQL injection via dynamic identifier interpolation.\n *\n * @see AGENTS.md § Database: Never Interpolate Into SQL\n */\n\n/**\n * Pattern for safe SQL identifiers.\n * Must start with a lowercase letter, followed by lowercase letters, digits, or underscores.\n */\nconst IDENTIFIER_PATTERN = /^[a-z][a-z0-9_]*$/;\n\n/**\n * Pattern for generic alphanumeric identifiers (case-insensitive).\n * Must start with a letter, followed by letters, digits, or underscores.\n */\nconst GENERIC_IDENTIFIER_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;\n\n/**\n * Pattern for plugin identifiers.\n * Must start with a lowercase letter, followed by lowercase letters, digits, underscores, or hyphens.\n */\nconst PLUGIN_IDENTIFIER_PATTERN = /^[a-z][a-z0-9_-]*$/;\n\n/**\n * Maximum length for SQL identifiers.\n * SQLite has no formal limit, but we cap at 128 for sanity.\n */\nconst MAX_IDENTIFIER_LENGTH = 128;\n\n/**\n * Error thrown when an identifier fails validation.\n */\nexport class IdentifierError extends Error {\n\tconstructor(\n\t\tmessage: string,\n\t\tpublic identifier: string,\n\t) {\n\t\tsuper(message);\n\t\tthis.name = \"IdentifierError\";\n\t}\n}\n\n/**\n * Validate that a string is a safe SQL identifier.\n *\n * Safe identifiers match `/^[a-z][a-z0-9_]*$/` and are at most 128 characters.\n * This prevents SQL injection when identifiers must be interpolated into raw SQL\n * (e.g., dynamic table names, column names in json_extract paths).\n *\n * @param value - The string to validate\n * @param label - Human-readable label for error messages (e.g., \"field name\", \"table name\")\n * @throws {IdentifierError} If the value is not a valid identifier\n *\n * @example\n * ```typescript\n * validateIdentifier(fieldName, \"field name\");\n * // safe to use in: json_extract(data, '$.${fieldName}')\n * ```\n */\nexport function validateIdentifier(value: string, label = \"identifier\"): void {\n\tif (!value || typeof value !== \"string\") {\n\t\tthrow new IdentifierError(`${label} must be a non-empty string`, String(value));\n\t}\n\n\tif (value.length > MAX_IDENTIFIER_LENGTH) {\n\t\tthrow new IdentifierError(\n\t\t\t`${label} must be ${MAX_IDENTIFIER_LENGTH} characters or less, got ${value.length}`,\n\t\t\tvalue,\n\t\t);\n\t}\n\n\tif (!IDENTIFIER_PATTERN.test(value)) {\n\t\tthrow new IdentifierError(`${label} must match /^[a-z][a-z0-9_]*$/ (got \"${value}\")`, value);\n\t}\n}\n\n/**\n * Validate that a string is a safe JSON field name for use in json_extract paths.\n *\n * More permissive than `validateIdentifier` — allows camelCase (mixed case)\n * since JSON keys in plugin storage data blobs commonly use camelCase.\n * Matches `/^[a-zA-Z][a-zA-Z0-9_]*$/`.\n *\n * @param value - The string to validate\n * @param label - Human-readable label for error messages\n * @throws {IdentifierError} If the value is not valid\n */\nexport function validateJsonFieldName(value: string, label = \"JSON field name\"): void {\n\tif (!value || typeof value !== \"string\") {\n\t\tthrow new IdentifierError(`${label} must be a non-empty string`, String(value));\n\t}\n\n\tif (value.length > MAX_IDENTIFIER_LENGTH) {\n\t\tthrow new IdentifierError(\n\t\t\t`${label} must be ${MAX_IDENTIFIER_LENGTH} characters or less, got ${value.length}`,\n\t\t\tvalue,\n\t\t);\n\t}\n\n\tif (!GENERIC_IDENTIFIER_PATTERN.test(value)) {\n\t\tthrow new IdentifierError(\n\t\t\t`${label} must match /^[a-zA-Z][a-zA-Z0-9_]*$/ (got \"${value}\")`,\n\t\t\tvalue,\n\t\t);\n\t}\n}\n\n/**\n * Validate that a string is a safe SQL identifier, allowing hyphens.\n *\n * Like `validateIdentifier` but also permits hyphens, which appear in\n * plugin IDs (e.g., \"my-plugin\"). Matches `/^[a-z][a-z0-9_-]*$/`.\n *\n * @param value - The string to validate\n * @param label - Human-readable label for error messages\n * @throws {IdentifierError} If the value is not valid\n */\nexport function validatePluginIdentifier(value: string, label = \"plugin identifier\"): void {\n\tif (!value || typeof value !== \"string\") {\n\t\tthrow new IdentifierError(`${label} must be a non-empty string`, String(value));\n\t}\n\n\tif (value.length > MAX_IDENTIFIER_LENGTH) {\n\t\tthrow new IdentifierError(\n\t\t\t`${label} must be ${MAX_IDENTIFIER_LENGTH} characters or less, got ${value.length}`,\n\t\t\tvalue,\n\t\t);\n\t}\n\n\tif (!PLUGIN_IDENTIFIER_PATTERN.test(value)) {\n\t\tthrow new IdentifierError(`${label} must match /^[a-z][a-z0-9_-]*$/ (got \"${value}\")`, value);\n\t}\n}\n"],"mappings":";;;;;;;;;;;;;;AAcA,MAAM,qBAAqB;;;;;AAM3B,MAAM,6BAA6B;;;;;AAMnC,MAAM,4BAA4B;;;;;AAMlC,MAAM,wBAAwB;;;;AAK9B,IAAa,kBAAb,cAAqC,MAAM;CAC1C,YACC,SACA,AAAO,YACN;AACD,QAAM,QAAQ;EAFP;AAGP,OAAK,OAAO;;;;;;;;;;;;;;;;;;;;AAqBd,SAAgB,mBAAmB,OAAe,QAAQ,cAAoB;AAC7E,KAAI,CAAC,SAAS,OAAO,UAAU,SAC9B,OAAM,IAAI,gBAAgB,GAAG,MAAM,8BAA8B,OAAO,MAAM,CAAC;AAGhF,KAAI,MAAM,SAAS,sBAClB,OAAM,IAAI,gBACT,GAAG,MAAM,WAAW,sBAAsB,2BAA2B,MAAM,UAC3E,MACA;AAGF,KAAI,CAAC,mBAAmB,KAAK,MAAM,CAClC,OAAM,IAAI,gBAAgB,GAAG,MAAM,wCAAwC,MAAM,KAAK,MAAM;;;;;;;;;;;;;AAe9F,SAAgB,sBAAsB,OAAe,QAAQ,mBAAyB;AACrF,KAAI,CAAC,SAAS,OAAO,UAAU,SAC9B,OAAM,IAAI,gBAAgB,GAAG,MAAM,8BAA8B,OAAO,MAAM,CAAC;AAGhF,KAAI,MAAM,SAAS,sBAClB,OAAM,IAAI,gBACT,GAAG,MAAM,WAAW,sBAAsB,2BAA2B,MAAM,UAC3E,MACA;AAGF,KAAI,CAAC,2BAA2B,KAAK,MAAM,CAC1C,OAAM,IAAI,gBACT,GAAG,MAAM,8CAA8C,MAAM,KAC7D,MACA;;;;;;;;;;;;AAcH,SAAgB,yBAAyB,OAAe,QAAQ,qBAA2B;AAC1F,KAAI,CAAC,SAAS,OAAO,UAAU,SAC9B,OAAM,IAAI,gBAAgB,GAAG,MAAM,8BAA8B,OAAO,MAAM,CAAC;AAGhF,KAAI,MAAM,SAAS,sBAClB,OAAM,IAAI,gBACT,GAAG,MAAM,WAAW,sBAAsB,2BAA2B,MAAM,UAC3E,MACA;AAGF,KAAI,CAAC,0BAA0B,KAAK,MAAM,CACzC,OAAM,IAAI,gBAAgB,GAAG,MAAM,yCAAyC,MAAM,KAAK,MAAM"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"version-DlTDRdpv.mjs","names":[],"sources":["../src/version.ts"],"sourcesContent":["/**\n * Build-time version constants, replaced by tsdown/Vite `define`.\n * Falls back to \"dev\" when running uncompiled (tests, dev).\n */\n\ndeclare const __EMDASH_VERSION__: string;\ndeclare const __EMDASH_COMMIT__: string;\n\nexport const VERSION: string =\n\ttypeof __EMDASH_VERSION__ !== \"undefined\" ? __EMDASH_VERSION__ : \"dev\";\n\nexport const COMMIT: string = typeof __EMDASH_COMMIT__ !== \"undefined\" ? __EMDASH_COMMIT__ : \"dev\";\n"],"mappings":";AAQA,MAAa;AAGb,MAAa"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "emdash",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Astro-native CMS with WordPress migration support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.mjs",
|
|
@@ -141,6 +141,8 @@
|
|
|
141
141
|
"#media/*": "./src/media/*",
|
|
142
142
|
"#mcp/*": "./src/mcp/*",
|
|
143
143
|
"#comments/*": "./src/comments/*",
|
|
144
|
+
"#bylines/*": "./src/bylines/*",
|
|
145
|
+
"#redirects/*": "./src/redirects/*",
|
|
144
146
|
"#types": "./src/astro/types.js"
|
|
145
147
|
},
|
|
146
148
|
"dependencies": {
|
|
@@ -178,9 +180,9 @@
|
|
|
178
180
|
"ulidx": "^2.4.1",
|
|
179
181
|
"upng-js": "^2.1.0",
|
|
180
182
|
"zod": "^4.3.5",
|
|
181
|
-
"@emdash-cms/admin": "0.
|
|
182
|
-
"@emdash-cms/auth": "0.
|
|
183
|
-
"@emdash-cms/gutenberg-to-portable-text": "0.
|
|
183
|
+
"@emdash-cms/admin": "0.4.0",
|
|
184
|
+
"@emdash-cms/auth": "0.4.0",
|
|
185
|
+
"@emdash-cms/gutenberg-to-portable-text": "0.4.0"
|
|
184
186
|
},
|
|
185
187
|
"optionalDependencies": {
|
|
186
188
|
"@libsql/kysely-libsql": "^0.4.0",
|
|
@@ -208,7 +210,7 @@
|
|
|
208
210
|
"vite": "^6.0.0",
|
|
209
211
|
"vitest": "^4.0.18",
|
|
210
212
|
"zod-openapi": "^5.4.6",
|
|
211
|
-
"@emdash-cms/blocks": "0.
|
|
213
|
+
"@emdash-cms/blocks": "0.4.0"
|
|
212
214
|
},
|
|
213
215
|
"repository": {
|
|
214
216
|
"type": "git",
|
|
@@ -22,6 +22,7 @@ import { withTransaction } from "../../database/transaction.js";
|
|
|
22
22
|
import type { Database } from "../../database/types.js";
|
|
23
23
|
import { validateIdentifier } from "../../database/validate.js";
|
|
24
24
|
import { isI18nEnabled } from "../../i18n/config.js";
|
|
25
|
+
import { invalidateRedirectCache } from "../../redirects/cache.js";
|
|
25
26
|
import { encodeRev, validateRev } from "../rev.js";
|
|
26
27
|
import type { ApiResult, ContentListResponse, ContentResponse } from "../types.js";
|
|
27
28
|
|
|
@@ -503,37 +504,37 @@ export async function handleContentUpdate(
|
|
|
503
504
|
// Resolve slug → ID if needed
|
|
504
505
|
const resolvedId = (await resolveId(repo, collection, id)) ?? id;
|
|
505
506
|
|
|
506
|
-
//
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
if (!existing) {
|
|
510
|
-
return {
|
|
511
|
-
success: false,
|
|
512
|
-
error: { code: "NOT_FOUND", message: `Content item not found: ${id}` },
|
|
513
|
-
};
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
const revCheck = validateRev(body._rev, existing);
|
|
517
|
-
if (!revCheck.valid) {
|
|
518
|
-
return {
|
|
519
|
-
success: false,
|
|
520
|
-
error: { code: "CONFLICT", message: revCheck.message },
|
|
521
|
-
};
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
// Wrap content + SEO writes in a transaction for atomicity
|
|
507
|
+
// Wrap content + SEO writes in a transaction for atomicity.
|
|
508
|
+
// The _rev check is inside the transaction so the read-then-write
|
|
509
|
+
// is atomic -- no concurrent write can slip between the check and update.
|
|
526
510
|
const item = await withTransaction(db, async (trx) => {
|
|
527
511
|
const trxRepo = new ContentRepository(trx);
|
|
528
512
|
const bylineRepo = new BylineRepository(trx);
|
|
529
513
|
|
|
514
|
+
// Read existing item once for both _rev check and old slug capture
|
|
515
|
+
const existing =
|
|
516
|
+
body._rev || body.slug ? await trxRepo.findById(collection, resolvedId) : null;
|
|
517
|
+
|
|
518
|
+
// Validate _rev if provided (optimistic concurrency)
|
|
519
|
+
if (body._rev) {
|
|
520
|
+
if (!existing) {
|
|
521
|
+
throw Object.assign(new Error(`Content item not found: ${id}`), {
|
|
522
|
+
apiError: { code: "NOT_FOUND" as const },
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const revCheck = validateRev(body._rev, existing);
|
|
527
|
+
if (!revCheck.valid) {
|
|
528
|
+
throw Object.assign(new Error(revCheck.message), {
|
|
529
|
+
apiError: { code: "CONFLICT" as const },
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
530
534
|
// Capture old slug before update for auto-redirect
|
|
531
535
|
let oldSlug: string | undefined;
|
|
532
|
-
if (body.slug) {
|
|
533
|
-
|
|
534
|
-
if (existing?.slug && existing.slug !== body.slug) {
|
|
535
|
-
oldSlug = existing.slug;
|
|
536
|
-
}
|
|
536
|
+
if (body.slug && existing?.slug && existing.slug !== body.slug) {
|
|
537
|
+
oldSlug = existing.slug;
|
|
537
538
|
}
|
|
538
539
|
|
|
539
540
|
const updated = await trxRepo.update(collection, resolvedId, {
|
|
@@ -564,6 +565,7 @@ export async function handleContentUpdate(
|
|
|
564
565
|
resolvedId,
|
|
565
566
|
collectionRow?.url_pattern ?? null,
|
|
566
567
|
);
|
|
568
|
+
invalidateRedirectCache();
|
|
567
569
|
}
|
|
568
570
|
|
|
569
571
|
// Sync non-translatable fields to sibling locales in the same
|
|
@@ -598,6 +600,15 @@ export async function handleContentUpdate(
|
|
|
598
600
|
data: { item, _rev: encodeRev(item) },
|
|
599
601
|
};
|
|
600
602
|
} catch (error) {
|
|
603
|
+
// Handle structured errors thrown from inside the transaction
|
|
604
|
+
// (rev check failures, not-found)
|
|
605
|
+
if (error instanceof Error && "apiError" in error) {
|
|
606
|
+
const { code } = (error as Error & { apiError: { code: string } }).apiError;
|
|
607
|
+
return {
|
|
608
|
+
success: false,
|
|
609
|
+
error: { code, message: error.message },
|
|
610
|
+
};
|
|
611
|
+
}
|
|
601
612
|
console.error("Content update error:", error);
|
|
602
613
|
return {
|
|
603
614
|
success: false,
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { Kysely } from "kysely";
|
|
9
9
|
import { ulid } from "ulidx";
|
|
10
10
|
|
|
11
|
+
import { withTransaction } from "../../database/transaction.js";
|
|
11
12
|
import type { Database, MenuItemTable, MenuTable } from "../../database/types.js";
|
|
12
13
|
import type { ApiResult } from "../types.js";
|
|
13
14
|
|
|
@@ -464,24 +465,26 @@ export async function handleMenuItemReorder(
|
|
|
464
465
|
};
|
|
465
466
|
}
|
|
466
467
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
468
|
+
const updatedItems = await withTransaction(db, async (trx) => {
|
|
469
|
+
for (const item of items) {
|
|
470
|
+
await trx
|
|
471
|
+
.updateTable("_emdash_menu_items")
|
|
472
|
+
.set({
|
|
473
|
+
parent_id: item.parentId,
|
|
474
|
+
sort_order: item.sortOrder,
|
|
475
|
+
})
|
|
476
|
+
.where("id", "=", item.id)
|
|
477
|
+
.where("menu_id", "=", menu.id)
|
|
478
|
+
.execute();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return trx
|
|
482
|
+
.selectFrom("_emdash_menu_items")
|
|
483
|
+
.selectAll()
|
|
475
484
|
.where("menu_id", "=", menu.id)
|
|
485
|
+
.orderBy("sort_order", "asc")
|
|
476
486
|
.execute();
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
const updatedItems = await db
|
|
480
|
-
.selectFrom("_emdash_menu_items")
|
|
481
|
-
.selectAll()
|
|
482
|
-
.where("menu_id", "=", menu.id)
|
|
483
|
-
.orderBy("sort_order", "asc")
|
|
484
|
-
.execute();
|
|
487
|
+
});
|
|
485
488
|
|
|
486
489
|
return { success: true, data: updatedItems };
|
|
487
490
|
} catch {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { Kysely } from "kysely";
|
|
6
6
|
|
|
7
|
+
import { OptionsRepository } from "../../database/repositories/options.js";
|
|
7
8
|
import {
|
|
8
9
|
RedirectRepository,
|
|
9
10
|
type Redirect,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
} from "../../database/repositories/redirect.js";
|
|
13
14
|
import type { FindManyResult } from "../../database/repositories/types.js";
|
|
14
15
|
import type { Database } from "../../database/types.js";
|
|
16
|
+
import { wouldCreateLoop, detectLoops, type RedirectEdge } from "../../redirects/loops.js";
|
|
15
17
|
import { validatePattern, validateDestinationParams, isPattern } from "../../redirects/patterns.js";
|
|
16
18
|
import type { ApiResult } from "../types.js";
|
|
17
19
|
|
|
@@ -32,11 +34,20 @@ export async function handleRedirectList(
|
|
|
32
34
|
enabled?: boolean;
|
|
33
35
|
auto?: boolean;
|
|
34
36
|
},
|
|
35
|
-
): Promise<ApiResult<FindManyResult<Redirect
|
|
37
|
+
): Promise<ApiResult<FindManyResult<Redirect> & { loopRedirectIds?: string[] }>> {
|
|
36
38
|
try {
|
|
37
39
|
const repo = new RedirectRepository(db);
|
|
38
40
|
const result = await repo.findMany(params);
|
|
39
|
-
|
|
41
|
+
|
|
42
|
+
const loopRedirectIds = await getLoopRedirectIds(db);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
success: true,
|
|
46
|
+
data: {
|
|
47
|
+
...result,
|
|
48
|
+
...(loopRedirectIds.length > 0 ? { loopRedirectIds } : {}),
|
|
49
|
+
},
|
|
50
|
+
};
|
|
40
51
|
} catch {
|
|
41
52
|
return {
|
|
42
53
|
success: false,
|
|
@@ -105,6 +116,13 @@ export async function handleRedirectCreate(
|
|
|
105
116
|
};
|
|
106
117
|
}
|
|
107
118
|
|
|
119
|
+
// Check for redirect loops (skip if creating as disabled)
|
|
120
|
+
if (input.enabled !== false) {
|
|
121
|
+
const edges = toEdges(await repo.findAllEnabled());
|
|
122
|
+
const loopPath = wouldCreateLoop(input.source, input.destination, edges);
|
|
123
|
+
if (loopPath) return loopError(loopPath);
|
|
124
|
+
}
|
|
125
|
+
|
|
108
126
|
const redirect = await repo.create({
|
|
109
127
|
source: input.source,
|
|
110
128
|
destination: input.destination,
|
|
@@ -219,7 +237,8 @@ export async function handleRedirectUpdate(
|
|
|
219
237
|
}
|
|
220
238
|
|
|
221
239
|
// Validate destination params against the (possibly updated) source
|
|
222
|
-
|
|
240
|
+
const newSourceIsPattern = isPattern(newSource);
|
|
241
|
+
if (newSourceIsPattern) {
|
|
223
242
|
const destError = validateDestinationParams(newSource, newDest);
|
|
224
243
|
if (destError) {
|
|
225
244
|
return {
|
|
@@ -229,6 +248,13 @@ export async function handleRedirectUpdate(
|
|
|
229
248
|
}
|
|
230
249
|
}
|
|
231
250
|
|
|
251
|
+
// Check for redirect loops if source or destination changed
|
|
252
|
+
if (input.source !== undefined || input.destination !== undefined) {
|
|
253
|
+
const edges = toEdges(await repo.findAllEnabled());
|
|
254
|
+
const loopPath = wouldCreateLoop(newSource, newDest, edges, id);
|
|
255
|
+
if (loopPath) return loopError(loopPath);
|
|
256
|
+
}
|
|
257
|
+
|
|
232
258
|
const updated = await repo.update(id, {
|
|
233
259
|
source: input.source,
|
|
234
260
|
destination: input.destination,
|
|
@@ -244,6 +270,9 @@ export async function handleRedirectUpdate(
|
|
|
244
270
|
};
|
|
245
271
|
}
|
|
246
272
|
|
|
273
|
+
// Recompute cache — redirect was modified, so re-fetch
|
|
274
|
+
await updateLoopCache(db);
|
|
275
|
+
|
|
247
276
|
return { success: true, data: updated };
|
|
248
277
|
} catch {
|
|
249
278
|
return {
|
|
@@ -271,6 +300,8 @@ export async function handleRedirectDelete(
|
|
|
271
300
|
};
|
|
272
301
|
}
|
|
273
302
|
|
|
303
|
+
await updateLoopCache(db);
|
|
304
|
+
|
|
274
305
|
return { success: true, data: { deleted: true } };
|
|
275
306
|
} catch {
|
|
276
307
|
return {
|
|
@@ -280,6 +311,67 @@ export async function handleRedirectDelete(
|
|
|
280
311
|
}
|
|
281
312
|
}
|
|
282
313
|
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Loop analysis cache
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
function loopError(loopPath: string[]): ApiResult<never> {
|
|
319
|
+
const hops = loopPath
|
|
320
|
+
.slice(0, -1)
|
|
321
|
+
.map((p, i) => `${p} \u2192 ${loopPath[i + 1]!}`)
|
|
322
|
+
.join("\n");
|
|
323
|
+
return {
|
|
324
|
+
success: false,
|
|
325
|
+
error: {
|
|
326
|
+
code: "VALIDATION_ERROR",
|
|
327
|
+
message: `This redirect would create a loop:\n${hops}`,
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function toEdges(redirects: Redirect[]): RedirectEdge[] {
|
|
333
|
+
return redirects.map((r) => ({
|
|
334
|
+
id: r.id,
|
|
335
|
+
source: r.source,
|
|
336
|
+
destination: r.destination,
|
|
337
|
+
enabled: r.enabled,
|
|
338
|
+
isPattern: r.isPattern,
|
|
339
|
+
}));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const LOOP_CACHE_KEY = "_redirect_loop_ids";
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Recompute loop redirect IDs and store in the options table.
|
|
346
|
+
*/
|
|
347
|
+
async function updateLoopCache(db: Kysely<Database>): Promise<void> {
|
|
348
|
+
try {
|
|
349
|
+
const options = new OptionsRepository(db);
|
|
350
|
+
const edges = toEdges(await new RedirectRepository(db).findAllEnabled());
|
|
351
|
+
const loopRedirectIds = detectLoops(edges);
|
|
352
|
+
await options.set(LOOP_CACHE_KEY, loopRedirectIds);
|
|
353
|
+
} catch (error) {
|
|
354
|
+
console.error("Failed to update redirect loop cache:", error);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Get loop redirect IDs from cache, computing lazily on first access.
|
|
360
|
+
*/
|
|
361
|
+
async function getLoopRedirectIds(db: Kysely<Database>): Promise<string[]> {
|
|
362
|
+
try {
|
|
363
|
+
const options = new OptionsRepository(db);
|
|
364
|
+
const cached = await options.get<string[]>(LOOP_CACHE_KEY);
|
|
365
|
+
if (cached !== null) return cached;
|
|
366
|
+
|
|
367
|
+
// First access after upgrade — compute and cache
|
|
368
|
+
await updateLoopCache(db);
|
|
369
|
+
return (await options.get<string[]>(LOOP_CACHE_KEY)) ?? [];
|
|
370
|
+
} catch {
|
|
371
|
+
return [];
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
283
375
|
// ---------------------------------------------------------------------------
|
|
284
376
|
// 404 Log
|
|
285
377
|
// ---------------------------------------------------------------------------
|
|
@@ -228,10 +228,9 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
|
|
|
228
228
|
injectBuiltinAuthRoutes(injectRoute);
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
-
// Inject MCP endpoint
|
|
232
|
-
if (resolvedConfig.mcp) {
|
|
231
|
+
// Inject MCP endpoint (always on — bearer-token-only, no cost if unused)
|
|
232
|
+
if (resolvedConfig.mcp !== false) {
|
|
233
233
|
injectMcpRoute(injectRoute);
|
|
234
|
-
logger.info("MCP server enabled at /_emdash/api/mcp");
|
|
235
234
|
}
|
|
236
235
|
|
|
237
236
|
// In playground mode, inject the playground middleware FIRST.
|
|
@@ -223,23 +223,17 @@ export interface EmDashConfig {
|
|
|
223
223
|
auth?: AuthDescriptor;
|
|
224
224
|
|
|
225
225
|
/**
|
|
226
|
-
*
|
|
226
|
+
* MCP (Model Context Protocol) server endpoint.
|
|
227
227
|
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
228
|
+
* Exposes an MCP Streamable HTTP server at `/_emdash/api/mcp`
|
|
229
|
+
* that allows AI agents and tools to interact with the CMS using
|
|
230
|
+
* the standardized MCP protocol.
|
|
231
231
|
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
232
|
+
* Enabled by default. The endpoint requires bearer token auth, so
|
|
233
|
+
* it has no effect unless the user creates an API token and
|
|
234
|
+
* configures a client. Set to `false` to disable.
|
|
234
235
|
*
|
|
235
|
-
* @default
|
|
236
|
-
*
|
|
237
|
-
* @example
|
|
238
|
-
* ```ts
|
|
239
|
-
* emdash({
|
|
240
|
-
* mcp: true,
|
|
241
|
-
* })
|
|
242
|
-
* ```
|
|
236
|
+
* @default true
|
|
243
237
|
*/
|
|
244
238
|
mcp?: boolean;
|
|
245
239
|
|
|
@@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
import type { AstroConfig } from "astro";
|
|
14
14
|
import type { Plugin } from "vite";
|
|
15
15
|
|
|
16
|
+
import { COMMIT, VERSION } from "../../version.js";
|
|
16
17
|
import type { EmDashConfig, PluginDescriptor } from "./runtime.js";
|
|
17
18
|
import {
|
|
18
19
|
VIRTUAL_CONFIG_ID,
|
|
@@ -278,6 +279,15 @@ export function createViteConfig(
|
|
|
278
279
|
const useSource = adminSourcePath !== undefined;
|
|
279
280
|
|
|
280
281
|
return {
|
|
282
|
+
// Astro SSR routes resolve version.ts from source (not tsdown dist),
|
|
283
|
+
// so Vite needs its own define pass for the __EMDASH_*__ placeholders.
|
|
284
|
+
define: {
|
|
285
|
+
__EMDASH_VERSION__: JSON.stringify(VERSION),
|
|
286
|
+
__EMDASH_COMMIT__: JSON.stringify(COMMIT),
|
|
287
|
+
__EMDASH_PSEUDO_LOCALE__: JSON.stringify(
|
|
288
|
+
isDev && process.env["EMDASH_PSEUDO_LOCALE"] === "1",
|
|
289
|
+
),
|
|
290
|
+
},
|
|
281
291
|
resolve: {
|
|
282
292
|
dedupe: ["@emdash-cms/admin", "react", "react-dom"],
|
|
283
293
|
// Array form so more-specific entries are checked first.
|
|
@@ -286,10 +296,6 @@ export function createViteConfig(
|
|
|
286
296
|
// "@emdash-cms/admin/styles.css" through the source directory.
|
|
287
297
|
alias: [
|
|
288
298
|
{ find: "@emdash-cms/admin/styles.css", replacement: resolve(adminDistPath, "styles.css") },
|
|
289
|
-
{
|
|
290
|
-
find: "@emdash-cms/admin/locales/*",
|
|
291
|
-
replacement: resolve(adminDistPath, "locales", "*"),
|
|
292
|
-
},
|
|
293
299
|
{ find: "@emdash-cms/admin", replacement: useSource ? adminSourcePath : adminDistPath },
|
|
294
300
|
],
|
|
295
301
|
},
|
|
@@ -342,6 +348,10 @@ export function createViteConfig(
|
|
|
342
348
|
"emdash > @emdash-cms/auth > @oslojs/crypto/ecdsa",
|
|
343
349
|
"emdash > @emdash-cms/auth > @oslojs/crypto/sha2",
|
|
344
350
|
"emdash > @emdash-cms/auth > @oslojs/webauthn",
|
|
351
|
+
// MCP SDK — server/index.js statically imports ajv (CJS-only).
|
|
352
|
+
// Pre-bundling converts CJS to ESM so workerd can load it.
|
|
353
|
+
"emdash > @modelcontextprotocol/sdk > ajv",
|
|
354
|
+
"emdash > @modelcontextprotocol/sdk > ajv-formats",
|
|
345
355
|
// React (commonly used, may be hoisted)
|
|
346
356
|
"react",
|
|
347
357
|
"react/jsx-dev-runtime",
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
import { defineMiddleware } from "astro:middleware";
|
|
18
18
|
|
|
19
19
|
import { RedirectRepository } from "../../database/repositories/redirect.js";
|
|
20
|
+
import {
|
|
21
|
+
getCachedPatternRules,
|
|
22
|
+
matchCachedPatterns,
|
|
23
|
+
setCachedPatternRules,
|
|
24
|
+
} from "../../redirects/cache.js";
|
|
20
25
|
|
|
21
26
|
/** Paths that should never be intercepted by redirects */
|
|
22
27
|
const SKIP_PREFIXES = ["/_emdash", "/_image"];
|
|
@@ -48,21 +53,31 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
48
53
|
|
|
49
54
|
try {
|
|
50
55
|
const repo = new RedirectRepository(emdash.db);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
56
|
+
|
|
57
|
+
// 1. Exact match (fast, indexed)
|
|
58
|
+
const exact = await repo.findExactMatch(pathname);
|
|
59
|
+
if (exact) {
|
|
60
|
+
const dest = exact.destination;
|
|
61
|
+
if (dest.startsWith("//") || dest.startsWith("/\\")) return next();
|
|
62
|
+
repo.recordHit(exact.id).catch(() => {});
|
|
63
|
+
const code = isRedirectCode(exact.type) ? exact.type : 301;
|
|
64
|
+
return context.redirect(dest, code);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 2. Pattern match (cached: compile once, match every request)
|
|
68
|
+
let rules = getCachedPatternRules();
|
|
69
|
+
if (!rules) {
|
|
70
|
+
const patterns = await repo.findEnabledPatternRules();
|
|
71
|
+
rules = setCachedPatternRules(patterns);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const patternMatch = matchCachedPatterns(rules, pathname);
|
|
75
|
+
if (patternMatch) {
|
|
76
|
+
const { redirect, destination } = patternMatch;
|
|
77
|
+
if (destination.startsWith("//") || destination.startsWith("/\\")) return next();
|
|
78
|
+
repo.recordHit(redirect.id).catch(() => {});
|
|
79
|
+
const code = isRedirectCode(redirect.type) ? redirect.type : 301;
|
|
80
|
+
return context.redirect(destination, code);
|
|
66
81
|
}
|
|
67
82
|
|
|
68
83
|
// No redirect matched -- proceed and check for 404
|
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) {
|
|
@@ -12,10 +12,10 @@ import AdminWrapper from "emdash/routes/PluginRegistry";
|
|
|
12
12
|
|
|
13
13
|
export const prerender = false;
|
|
14
14
|
|
|
15
|
-
import { resolveLocale } from "@emdash-cms/admin/locales";
|
|
15
|
+
import { resolveLocale, loadMessages } from "@emdash-cms/admin/locales";
|
|
16
16
|
|
|
17
17
|
const resolvedLocale = resolveLocale(Astro.request);
|
|
18
|
-
const
|
|
18
|
+
const messages = await loadMessages(resolvedLocale);
|
|
19
19
|
---
|
|
20
20
|
|
|
21
21
|
<!doctype html>
|
|
@@ -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");
|