emdash 0.6.0 → 1.0.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-Di31kZ28.d.mts → adapters-BKSf3T9R.d.mts} +1 -1
- package/dist/{adapters-Di31kZ28.d.mts.map → adapters-BKSf3T9R.d.mts.map} +1 -1
- package/dist/{apply-B4MsLM-w.mjs → apply-x0eMK1lX.mjs} +186 -28
- package/dist/apply-x0eMK1lX.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 +92 -17
- 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 +22 -2
- package/dist/astro/middleware/auth.mjs.map +1 -1
- package/dist/astro/middleware/redirect.mjs +2 -2
- package/dist/astro/middleware/request-context.mjs +7 -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 +263 -74
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +25 -8
- package/dist/astro/types.d.mts.map +1 -1
- package/dist/{byline-C4OVd8b3.mjs → byline-Chbr2GoP.mjs} +3 -3
- package/dist/byline-Chbr2GoP.mjs.map +1 -0
- package/dist/{bylines-hPTW79hw.mjs → bylines-CRNsVG88.mjs} +4 -4
- package/dist/{bylines-hPTW79hw.mjs.map → bylines-CRNsVG88.mjs.map} +1 -1
- package/dist/cli/index.mjs +17 -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/{content-BsBoyj8G.mjs → content-BcQPYxdV.mjs} +39 -15
- package/dist/content-BcQPYxdV.mjs.map +1 -0
- 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/{db-errors-D0UT85nC.mjs → db-errors-l1Qh2RPR.mjs} +1 -1
- package/dist/{db-errors-D0UT85nC.mjs.map → db-errors-l1Qh2RPR.mjs.map} +1 -1
- package/dist/{default-CME5YdZ3.mjs → default-DCVqE5ib.mjs} +1 -1
- package/dist/{default-CME5YdZ3.mjs.map → default-DCVqE5ib.mjs.map} +1 -1
- package/dist/{error-CiYn9yDu.mjs → error-zG5T1UGA.mjs} +1 -1
- package/dist/error-zG5T1UGA.mjs.map +1 -0
- package/dist/{index-BYv0mB9g.d.mts → index-DIb-CzNx.d.mts} +232 -15
- package/dist/index-DIb-CzNx.d.mts.map +1 -0
- package/dist/index.d.mts +11 -11
- package/dist/index.mjs +23 -21
- package/dist/{load-CBcmDIot.mjs → load-CyEoextb.mjs} +1 -1
- package/dist/{load-CBcmDIot.mjs.map → load-CyEoextb.mjs.map} +1 -1
- package/dist/{loader-DeiBJEMe.mjs → loader-CndGj8kM.mjs} +8 -6
- package/dist/loader-CndGj8kM.mjs.map +1 -0
- package/dist/{manifest-schema-V30qsMft.mjs → manifest-schema-DH9xhc6t.mjs} +13 -1
- package/dist/manifest-schema-DH9xhc6t.mjs.map +1 -0
- package/dist/media/index.d.mts +1 -1
- package/dist/media/local-runtime.d.mts +7 -7
- package/dist/media/local-runtime.mjs +2 -2
- package/dist/{media-DqHVh136.mjs → media-D8FbNsl0.mjs} +4 -7
- package/dist/media-D8FbNsl0.mjs.map +1 -0
- package/dist/{mode-CpNnGkPz.mjs → mode-BnAOqItE.mjs} +1 -1
- package/dist/mode-BnAOqItE.mjs.map +1 -0
- package/dist/page/index.d.mts +2 -2
- package/dist/placeholder-C-fk5hYI.mjs.map +1 -1
- package/dist/{placeholder-tzpqGWII.d.mts → placeholder-D29tWZ7o.d.mts} +1 -1
- package/dist/{placeholder-tzpqGWII.d.mts.map → placeholder-D29tWZ7o.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-Bk_3vKvU.mjs → query-fqEdLFms.mjs} +9 -9
- package/dist/{query-Bk_3vKvU.mjs.map → query-fqEdLFms.mjs.map} +1 -1
- package/dist/{redirect-7lGhLBNZ.mjs → redirect-D_pshWdf.mjs} +69 -13
- package/dist/redirect-D_pshWdf.mjs.map +1 -0
- package/dist/{registry-Ci3WxVAr.mjs → registry-C3Mr0ODu.mjs} +33 -9
- package/dist/registry-C3Mr0ODu.mjs.map +1 -0
- package/dist/{request-cache-DiR961CV.mjs → request-cache-Ci7f5pBb.mjs} +1 -1
- package/dist/request-cache-Ci7f5pBb.mjs.map +1 -0
- package/dist/{runner-Fl2NcUUz.d.mts → runner-OURCaApa.d.mts} +2 -2
- package/dist/{runner-Fl2NcUUz.d.mts.map → runner-OURCaApa.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 +2 -2
- package/dist/{search-DI4bM2w9.mjs → search-BoZYFuUk.mjs} +339 -102
- package/dist/search-BoZYFuUk.mjs.map +1 -0
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +12 -12
- 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.d.mts.map +1 -1
- package/dist/storage/s3.mjs +4 -4
- package/dist/storage/s3.mjs.map +1 -1
- package/dist/{taxonomies-DbrKzDju.mjs → taxonomies-B4IAshV8.mjs} +5 -5
- package/dist/{taxonomies-DbrKzDju.mjs.map → taxonomies-B4IAshV8.mjs.map} +1 -1
- package/dist/{tokens-BFPFx3CA.mjs → tokens-D9vnZqYS.mjs} +1 -1
- package/dist/{tokens-BFPFx3CA.mjs.map → tokens-D9vnZqYS.mjs.map} +1 -1
- package/dist/{transport-BykRfpyy.mjs → transport-C9ugt2Nr.mjs} +1 -1
- package/dist/{transport-BykRfpyy.mjs.map → transport-C9ugt2Nr.mjs.map} +1 -1
- package/dist/{transport-H4Iwx7tC.d.mts → transport-CUnEL3Vs.d.mts} +1 -1
- package/dist/{transport-H4Iwx7tC.d.mts.map → transport-CUnEL3Vs.d.mts.map} +1 -1
- package/dist/types-BIgulNsW.mjs +68 -0
- package/dist/types-BIgulNsW.mjs.map +1 -0
- package/dist/{types-DDS4MxsT.mjs → types-Bm1dn-q3.mjs} +1 -1
- package/dist/{types-DDS4MxsT.mjs.map → types-Bm1dn-q3.mjs.map} +1 -1
- package/dist/{types-CnZYHyLW.d.mts → types-BmPPSUEx.d.mts} +1 -1
- package/dist/{types-CnZYHyLW.d.mts.map → types-BmPPSUEx.d.mts.map} +1 -1
- package/dist/{types-6CUZRrZP.d.mts → types-BrA0xf5I.d.mts} +24 -2
- package/dist/{types-6CUZRrZP.d.mts.map → types-BrA0xf5I.d.mts.map} +1 -1
- package/dist/{types-8xrvl_68.d.mts → types-CS8FIX7L.d.mts} +10 -1
- package/dist/{types-8xrvl_68.d.mts.map → types-CS8FIX7L.d.mts.map} +1 -1
- package/dist/{types-BH2L167P.mjs → types-CgqmmMJB.mjs} +1 -1
- package/dist/{types-BH2L167P.mjs.map → types-CgqmmMJB.mjs.map} +1 -1
- package/dist/{types-CFWjXmus.d.mts → types-DIMwPFub.d.mts} +1 -1
- package/dist/{types-CFWjXmus.d.mts.map → types-DIMwPFub.d.mts.map} +1 -1
- package/dist/{types-DgrIP0tF.d.mts → types-i36XcA_X.d.mts} +49 -6
- package/dist/types-i36XcA_X.d.mts.map +1 -0
- package/dist/{validate-CqsNItbt.mjs → validate-CxVsLehf.mjs} +2 -2
- package/dist/{validate-CqsNItbt.mjs.map → validate-CxVsLehf.mjs.map} +1 -1
- package/dist/{validate-CaLH1Ia2.d.mts → validate-DHxmpFJt.d.mts} +4 -4
- package/dist/{validate-CaLH1Ia2.d.mts.map → validate-DHxmpFJt.d.mts.map} +1 -1
- package/dist/validation-C-ZpN2GI.mjs +144 -0
- package/dist/validation-C-ZpN2GI.mjs.map +1 -0
- package/dist/version-DJrV1K0M.mjs +7 -0
- package/dist/{version-Uaf2ynPX.mjs.map → version-DJrV1K0M.mjs.map} +1 -1
- package/dist/zod-generator-CpwccCIv.mjs +132 -0
- package/dist/zod-generator-CpwccCIv.mjs.map +1 -0
- package/package.json +19 -6
- package/src/api/auth-storage.ts +37 -0
- package/src/api/error.ts +6 -0
- package/src/api/errors.ts +8 -0
- package/src/api/handlers/comments.ts +13 -0
- package/src/api/handlers/content.ts +124 -3
- package/src/api/handlers/index.ts +2 -0
- package/src/api/handlers/media.ts +8 -1
- package/src/api/handlers/menus.ts +160 -21
- package/src/api/handlers/redirects.ts +16 -3
- package/src/api/handlers/sections.ts +8 -1
- package/src/api/handlers/taxonomies.ts +128 -16
- package/src/api/handlers/validation.ts +212 -0
- package/src/api/openapi/document.ts +4 -1
- package/src/api/public-url.ts +6 -3
- package/src/api/route-utils.ts +14 -0
- package/src/api/schemas/common.ts +1 -1
- package/src/api/schemas/content.ts +8 -0
- package/src/api/schemas/setup.ts +8 -0
- package/src/api/schemas/widgets.ts +12 -10
- package/src/api/setup-complete.ts +40 -0
- package/src/astro/integration/font-provider.ts +3 -1
- package/src/astro/integration/index.ts +15 -2
- package/src/astro/integration/routes.ts +28 -0
- package/src/astro/integration/runtime.ts +74 -2
- package/src/astro/integration/virtual-modules.ts +41 -0
- package/src/astro/integration/vite-config.ts +43 -12
- package/src/astro/middleware/auth.ts +21 -0
- package/src/astro/middleware.ts +18 -1
- package/src/astro/routes/PluginRegistry.tsx +10 -1
- package/src/astro/routes/admin.astro +14 -7
- package/src/astro/routes/api/auth/magic-link/send.ts +2 -1
- package/src/astro/routes/api/auth/mode.ts +57 -0
- package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +23 -3
- package/src/astro/routes/api/auth/oauth/[provider].ts +10 -4
- package/src/astro/routes/api/auth/passkey/options.ts +2 -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]/translations.ts +26 -0
- package/src/astro/routes/api/content/[collection]/[id].ts +30 -2
- package/src/astro/routes/api/content/[collection]/index.ts +20 -10
- package/src/astro/routes/api/content/[collection]/trash.ts +1 -1
- package/src/astro/routes/api/import/wordpress/media.ts +2 -7
- package/src/astro/routes/api/import/wordpress/prepare.ts +10 -0
- package/src/astro/routes/api/import/wordpress-plugin/analyze.ts +4 -3
- package/src/astro/routes/api/import/wordpress-plugin/execute.ts +4 -3
- package/src/astro/routes/api/manifest.ts +7 -0
- 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/settings/email.ts +4 -9
- package/src/astro/routes/api/setup/admin-verify.ts +30 -5
- package/src/astro/routes/api/setup/admin.ts +38 -8
- package/src/astro/routes/api/setup/index.ts +7 -4
- package/src/astro/routes/api/setup/status.ts +3 -1
- package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +4 -1
- package/src/astro/routes/api/widget-areas/[name]/widgets.ts +4 -1
- package/src/astro/routes/api/widget-areas/[name].ts +4 -1
- package/src/astro/routes/api/widget-areas/index.ts +4 -1
- package/src/astro/types.ts +18 -0
- package/src/auth/mode.ts +15 -3
- package/src/auth/providers/github-admin.tsx +29 -0
- package/src/auth/providers/github.ts +31 -0
- package/src/auth/providers/google-admin.tsx +44 -0
- package/src/auth/providers/google.ts +31 -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/auth/types.ts +114 -4
- package/src/cli/commands/bundle.ts +3 -1
- package/src/components/EmDashImage.astro +7 -6
- package/src/components/Gallery.astro +5 -3
- package/src/components/Image.astro +8 -3
- package/src/components/InlinePortableTextEditor.tsx +2 -1
- package/src/components/LiveSearch.astro +5 -14
- package/src/database/migrations/035_bounded_404_log.ts +112 -0
- package/src/database/migrations/runner.ts +2 -0
- package/src/database/repositories/audit.ts +6 -8
- package/src/database/repositories/byline.ts +6 -8
- package/src/database/repositories/comment.ts +12 -16
- package/src/database/repositories/content.ts +79 -40
- package/src/database/repositories/index.ts +1 -1
- package/src/database/repositories/media.ts +10 -13
- package/src/database/repositories/options.ts +25 -0
- package/src/database/repositories/plugin-storage.ts +4 -6
- package/src/database/repositories/redirect.ts +123 -24
- package/src/database/repositories/taxonomy.ts +14 -3
- package/src/database/repositories/types.ts +57 -8
- package/src/database/repositories/user.ts +6 -8
- package/src/database/types.ts +9 -0
- package/src/emdash-runtime.ts +309 -91
- package/src/import/registry.ts +4 -3
- package/src/import/ssrf.ts +253 -12
- package/src/index.ts +5 -1
- package/src/loader.ts +6 -5
- package/src/mcp/server.ts +753 -107
- package/src/media/normalize.ts +1 -1
- package/src/media/url.ts +78 -0
- package/src/plugins/context.ts +15 -3
- package/src/plugins/email-console.ts +10 -3
- package/src/plugins/hooks.ts +11 -0
- package/src/plugins/manager.ts +6 -0
- package/src/plugins/manifest-schema.ts +12 -0
- package/src/plugins/request-meta.ts +66 -15
- package/src/plugins/routes.ts +3 -1
- package/src/plugins/types.ts +23 -2
- package/src/query.ts +1 -1
- package/src/request-cache.ts +3 -0
- package/src/schema/registry.ts +41 -5
- package/src/search/fts-manager.ts +0 -2
- package/src/search/query.ts +111 -26
- package/src/search/types.ts +8 -1
- package/src/sections/index.ts +7 -9
- package/src/seed/apply.ts +26 -0
- package/src/storage/s3.ts +12 -6
- package/src/virtual-modules.d.ts +21 -1
- package/src/visual-editing/toolbar.ts +6 -1
- package/src/widgets/index.ts +1 -1
- package/dist/apply-B4MsLM-w.mjs.map +0 -1
- package/dist/byline-C4OVd8b3.mjs.map +0 -1
- package/dist/content-BsBoyj8G.mjs.map +0 -1
- package/dist/error-CiYn9yDu.mjs.map +0 -1
- package/dist/index-BYv0mB9g.d.mts.map +0 -1
- package/dist/loader-DeiBJEMe.mjs.map +0 -1
- package/dist/manifest-schema-V30qsMft.mjs.map +0 -1
- package/dist/media-DqHVh136.mjs.map +0 -1
- package/dist/mode-CpNnGkPz.mjs.map +0 -1
- package/dist/redirect-7lGhLBNZ.mjs.map +0 -1
- package/dist/registry-Ci3WxVAr.mjs.map +0 -1
- package/dist/request-cache-DiR961CV.mjs.map +0 -1
- package/dist/runner-Cd-_WyDo.mjs.map +0 -1
- package/dist/search-DI4bM2w9.mjs.map +0 -1
- package/dist/types-CMMN0pNg.mjs +0 -31
- package/dist/types-CMMN0pNg.mjs.map +0 -1
- package/dist/types-DgrIP0tF.d.mts.map +0 -1
- package/dist/version-Uaf2ynPX.mjs +0 -7
|
@@ -202,20 +202,17 @@ export class MediaRepository {
|
|
|
202
202
|
.orderBy("id", "desc")
|
|
203
203
|
.limit(limit + 1);
|
|
204
204
|
|
|
205
|
-
// Handle cursor-based pagination
|
|
205
|
+
// Handle cursor-based pagination — throws on invalid cursor.
|
|
206
206
|
if (options.cursor) {
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
eb.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
]),
|
|
217
|
-
);
|
|
218
|
-
}
|
|
207
|
+
const { orderValue: createdAt, id: cursorId } = decodeCursor(options.cursor);
|
|
208
|
+
|
|
209
|
+
// Keyset pagination: get items where (created_at, id) < cursor
|
|
210
|
+
query = query.where((eb) =>
|
|
211
|
+
eb.or([
|
|
212
|
+
eb("created_at", "<", createdAt),
|
|
213
|
+
eb.and([eb("created_at", "=", createdAt), eb("id", "<", cursorId)]),
|
|
214
|
+
]),
|
|
215
|
+
);
|
|
219
216
|
}
|
|
220
217
|
|
|
221
218
|
if (options.mimeType) {
|
|
@@ -55,6 +55,31 @@ export class OptionsRepository {
|
|
|
55
55
|
.execute();
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Set an option value only if no row with that name exists. Atomic at the
|
|
60
|
+
* database level via INSERT ... ON CONFLICT DO NOTHING, so concurrent
|
|
61
|
+
* callers can't race past the check.
|
|
62
|
+
*
|
|
63
|
+
* Returns true when the row was inserted, false when a row already
|
|
64
|
+
* existed (regardless of its value — even an empty string or null).
|
|
65
|
+
*/
|
|
66
|
+
async setIfAbsent<T = unknown>(name: string, value: T): Promise<boolean> {
|
|
67
|
+
const row: OptionTable = {
|
|
68
|
+
name,
|
|
69
|
+
value: JSON.stringify(value),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const result = await this.db
|
|
73
|
+
.insertInto("options")
|
|
74
|
+
.values(row)
|
|
75
|
+
.onConflict((oc) => oc.column("name").doNothing())
|
|
76
|
+
.executeTakeFirst();
|
|
77
|
+
|
|
78
|
+
// SQLite reports numInsertedOrUpdatedRows; Postgres reports the same.
|
|
79
|
+
// When the ON CONFLICT branch fires and does nothing, the count is 0.
|
|
80
|
+
return (result.numInsertedOrUpdatedRows ?? 0n) > 0n;
|
|
81
|
+
}
|
|
82
|
+
|
|
58
83
|
/**
|
|
59
84
|
* Delete an option
|
|
60
85
|
*/
|
|
@@ -226,14 +226,12 @@ export class PluginStorageRepository<T = unknown> implements StorageCollection<T
|
|
|
226
226
|
query = query.where(({ eb }) => eb(sql.join(whereSqlParts, sql.raw("")), "=", sql.raw("1")));
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
// Handle cursor-based pagination
|
|
229
|
+
// Handle cursor-based pagination — throws on invalid cursor.
|
|
230
230
|
if (cursor) {
|
|
231
231
|
const decoded = decodeCursor(cursor);
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
);
|
|
236
|
-
}
|
|
232
|
+
query = query.where(({ eb }) =>
|
|
233
|
+
eb(sql`(created_at, id)`, ">", sql`(${decoded.orderValue}, ${decoded.id})`),
|
|
234
|
+
);
|
|
237
235
|
}
|
|
238
236
|
|
|
239
237
|
// Build ORDER BY using sql template
|
|
@@ -11,6 +11,32 @@ import { currentTimestampValue } from "../dialect-helpers.js";
|
|
|
11
11
|
import type { Database, RedirectTable } from "../types.js";
|
|
12
12
|
import { encodeCursor, decodeCursor, type FindManyResult } from "./types.js";
|
|
13
13
|
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Bounded 404 logging
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Hard cap on rows stored in `_emdash_404_log`. When exceeded, the oldest
|
|
20
|
+
* rows (by `last_seen_at`) are evicted on insert. Prevents an unauthenticated
|
|
21
|
+
* attacker from growing the table without bound by requesting unique URLs.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_404_LOG_ROWS = 10_000;
|
|
24
|
+
|
|
25
|
+
/** Max stored length for the `Referer` header — truncated on insert. */
|
|
26
|
+
export const REFERRER_MAX_LENGTH = 512;
|
|
27
|
+
|
|
28
|
+
/** Max stored length for the `User-Agent` header — truncated on insert. */
|
|
29
|
+
export const USER_AGENT_MAX_LENGTH = 256;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Truncate a header-derived string to `max` chars, preserving `null`/`undefined`
|
|
33
|
+
* as `null`. Empty strings stay empty (the caller decides whether to coerce).
|
|
34
|
+
*/
|
|
35
|
+
function truncateOrNull(value: string | null | undefined, max: number): string | null {
|
|
36
|
+
if (value === null || value === undefined) return null;
|
|
37
|
+
return value.length > max ? value.slice(0, max) : value;
|
|
38
|
+
}
|
|
39
|
+
|
|
14
40
|
// ---------------------------------------------------------------------------
|
|
15
41
|
// Types
|
|
16
42
|
// ---------------------------------------------------------------------------
|
|
@@ -156,14 +182,12 @@ export class RedirectRepository {
|
|
|
156
182
|
|
|
157
183
|
if (opts.cursor) {
|
|
158
184
|
const decoded = decodeCursor(opts.cursor);
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
eb.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
);
|
|
166
|
-
}
|
|
185
|
+
query = query.where((eb) =>
|
|
186
|
+
eb.or([
|
|
187
|
+
eb("created_at", "<", decoded.orderValue),
|
|
188
|
+
eb.and([eb("created_at", "=", decoded.orderValue), eb("id", "<", decoded.id)]),
|
|
189
|
+
]),
|
|
190
|
+
);
|
|
167
191
|
}
|
|
168
192
|
|
|
169
193
|
const rows = await query.execute();
|
|
@@ -369,22 +393,97 @@ export class RedirectRepository {
|
|
|
369
393
|
|
|
370
394
|
// --- 404 log ------------------------------------------------------------
|
|
371
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Record a 404 hit for `entry.path`.
|
|
398
|
+
*
|
|
399
|
+
* Dedups by path: repeat hits increment `hits` and refresh `last_seen_at`
|
|
400
|
+
* on the existing row instead of inserting a new one. Referrer and
|
|
401
|
+
* user-agent are truncated to bounded lengths so a malicious client can't
|
|
402
|
+
* blow up storage with huge headers. When the table would exceed
|
|
403
|
+
* MAX_404_LOG_ROWS, the oldest entries (by `last_seen_at`) are evicted.
|
|
404
|
+
*
|
|
405
|
+
* This is called from the public redirect middleware on every 404 and
|
|
406
|
+
* must never throw for an unauthenticated caller — failures bubble up to
|
|
407
|
+
* the middleware, which swallows them.
|
|
408
|
+
*/
|
|
372
409
|
async log404(entry: {
|
|
373
410
|
path: string;
|
|
374
411
|
referrer?: string | null;
|
|
375
412
|
userAgent?: string | null;
|
|
376
413
|
ip?: string | null;
|
|
377
414
|
}): Promise<void> {
|
|
415
|
+
const now = new Date().toISOString();
|
|
416
|
+
const referrer = truncateOrNull(entry.referrer, REFERRER_MAX_LENGTH);
|
|
417
|
+
const userAgent = truncateOrNull(entry.userAgent, USER_AGENT_MAX_LENGTH);
|
|
418
|
+
const ip = entry.ip ?? null;
|
|
419
|
+
|
|
420
|
+
// Atomic upsert by path. The UNIQUE index on `path` makes this safe
|
|
421
|
+
// under concurrency: two requests for the same new path can't both
|
|
422
|
+
// insert — the second one hits the conflict branch and increments
|
|
423
|
+
// hits instead of failing with a uniqueness error.
|
|
378
424
|
await this.db
|
|
379
425
|
.insertInto("_emdash_404_log")
|
|
380
426
|
.values({
|
|
381
427
|
id: ulid(),
|
|
382
428
|
path: entry.path,
|
|
383
|
-
referrer
|
|
384
|
-
user_agent:
|
|
385
|
-
ip
|
|
386
|
-
|
|
429
|
+
referrer,
|
|
430
|
+
user_agent: userAgent,
|
|
431
|
+
ip,
|
|
432
|
+
hits: 1,
|
|
433
|
+
last_seen_at: now,
|
|
434
|
+
created_at: now,
|
|
387
435
|
})
|
|
436
|
+
.onConflict((oc) =>
|
|
437
|
+
oc.column("path").doUpdateSet({
|
|
438
|
+
hits: sql`hits + 1`,
|
|
439
|
+
last_seen_at: now,
|
|
440
|
+
referrer,
|
|
441
|
+
user_agent: userAgent,
|
|
442
|
+
ip,
|
|
443
|
+
}),
|
|
444
|
+
)
|
|
445
|
+
.execute();
|
|
446
|
+
|
|
447
|
+
// Enforce the row cap. Cheap when the table is under cap (single
|
|
448
|
+
// COUNT(*) query); evicts oldest rows if we're over. Updates (dedup
|
|
449
|
+
// hits) don't grow the table so this is a no-op for repeat paths.
|
|
450
|
+
await this.enforce404Cap();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Delete the oldest rows from `_emdash_404_log` if the row count exceeds
|
|
455
|
+
* MAX_404_LOG_ROWS. "Oldest" is by `last_seen_at`, so a path that keeps
|
|
456
|
+
* getting hit stays in the table even if it was first seen long ago.
|
|
457
|
+
*
|
|
458
|
+
* Private — callers use `log404`, which invokes this after every upsert.
|
|
459
|
+
*/
|
|
460
|
+
private async enforce404Cap(): Promise<void> {
|
|
461
|
+
const countRow = await this.db
|
|
462
|
+
.selectFrom("_emdash_404_log")
|
|
463
|
+
.select((eb) => eb.fn.countAll<number>().as("c"))
|
|
464
|
+
.executeTakeFirst();
|
|
465
|
+
const count = Number(countRow?.c ?? 0);
|
|
466
|
+
if (count <= MAX_404_LOG_ROWS) return;
|
|
467
|
+
|
|
468
|
+
const excess = count - MAX_404_LOG_ROWS;
|
|
469
|
+
|
|
470
|
+
// Evict the oldest rows in a single SQL statement. Using a subquery
|
|
471
|
+
// (rather than materialising the victim IDs in JS and passing them
|
|
472
|
+
// back as bind parameters) keeps the statement bounded regardless of
|
|
473
|
+
// how far over cap the table is — important for existing installs
|
|
474
|
+
// that crossed the threshold before this cap was introduced.
|
|
475
|
+
await this.db
|
|
476
|
+
.deleteFrom("_emdash_404_log")
|
|
477
|
+
.where(
|
|
478
|
+
"id",
|
|
479
|
+
"in",
|
|
480
|
+
this.db
|
|
481
|
+
.selectFrom("_emdash_404_log")
|
|
482
|
+
.select("id")
|
|
483
|
+
.orderBy("last_seen_at", "asc")
|
|
484
|
+
.orderBy("id", "asc")
|
|
485
|
+
.limit(excess),
|
|
486
|
+
)
|
|
388
487
|
.execute();
|
|
389
488
|
}
|
|
390
489
|
|
|
@@ -408,14 +507,12 @@ export class RedirectRepository {
|
|
|
408
507
|
|
|
409
508
|
if (opts.cursor) {
|
|
410
509
|
const decoded = decodeCursor(opts.cursor);
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
eb.
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
);
|
|
418
|
-
}
|
|
510
|
+
query = query.where((eb) =>
|
|
511
|
+
eb.or([
|
|
512
|
+
eb("created_at", "<", decoded.orderValue),
|
|
513
|
+
eb.and([eb("created_at", "=", decoded.orderValue), eb("id", "<", decoded.id)]),
|
|
514
|
+
]),
|
|
515
|
+
);
|
|
419
516
|
}
|
|
420
517
|
|
|
421
518
|
const rows = await query.execute();
|
|
@@ -438,6 +535,10 @@ export class RedirectRepository {
|
|
|
438
535
|
}
|
|
439
536
|
|
|
440
537
|
async get404Summary(limit = 50): Promise<NotFoundSummary[]> {
|
|
538
|
+
// Since rows are now deduped by path, each path has exactly one row
|
|
539
|
+
// with `hits` as the running count and `last_seen_at` as the latest
|
|
540
|
+
// timestamp. The subquery for `top_referrer` collapses to a simple
|
|
541
|
+
// pick of the row's stored referrer (the most recent one seen).
|
|
441
542
|
const rows = await sql<{
|
|
442
543
|
path: string;
|
|
443
544
|
count: number;
|
|
@@ -446,14 +547,12 @@ export class RedirectRepository {
|
|
|
446
547
|
}>`
|
|
447
548
|
SELECT
|
|
448
549
|
path,
|
|
449
|
-
|
|
450
|
-
MAX(
|
|
550
|
+
SUM(hits) as count,
|
|
551
|
+
MAX(last_seen_at) as last_seen,
|
|
451
552
|
(
|
|
452
553
|
SELECT referrer FROM _emdash_404_log AS inner_log
|
|
453
554
|
WHERE inner_log.path = _emdash_404_log.path
|
|
454
555
|
AND referrer IS NOT NULL AND referrer != ''
|
|
455
|
-
GROUP BY referrer
|
|
456
|
-
ORDER BY COUNT(*) DESC
|
|
457
556
|
LIMIT 1
|
|
458
557
|
) as top_referrer
|
|
459
558
|
FROM _emdash_404_log
|
|
@@ -41,12 +41,15 @@ export class TaxonomyRepository {
|
|
|
41
41
|
async create(input: CreateTaxonomyInput): Promise<Taxonomy> {
|
|
42
42
|
const id = ulid();
|
|
43
43
|
|
|
44
|
+
// Empty-string parentId is coerced to null defensively. Higher layers
|
|
45
|
+
// also normalize this — see handleTermCreate / handleTermUpdate.
|
|
46
|
+
const parentId = input.parentId === undefined || input.parentId === "" ? null : input.parentId;
|
|
44
47
|
const row: TaxonomyTable = {
|
|
45
48
|
id,
|
|
46
49
|
name: input.name,
|
|
47
50
|
slug: input.slug,
|
|
48
51
|
label: input.label,
|
|
49
|
-
parent_id:
|
|
52
|
+
parent_id: parentId,
|
|
50
53
|
data: input.data ? JSON.stringify(input.data) : null,
|
|
51
54
|
};
|
|
52
55
|
|
|
@@ -90,11 +93,15 @@ export class TaxonomyRepository {
|
|
|
90
93
|
* Get all terms for a taxonomy (e.g., all categories)
|
|
91
94
|
*/
|
|
92
95
|
async findByName(name: string, options: { parentId?: string | null } = {}): Promise<Taxonomy[]> {
|
|
96
|
+
// `id asc` is a stable tiebreaker for terms that share a label.
|
|
97
|
+
// Without it the SQL ordering is implementation-defined when labels
|
|
98
|
+
// match, which breaks keyset pagination over `(label, id)`.
|
|
93
99
|
let query = this.db
|
|
94
100
|
.selectFrom("taxonomies")
|
|
95
101
|
.selectAll()
|
|
96
102
|
.where("name", "=", name)
|
|
97
|
-
.orderBy("label", "asc")
|
|
103
|
+
.orderBy("label", "asc")
|
|
104
|
+
.orderBy("id", "asc");
|
|
98
105
|
|
|
99
106
|
if (options.parentId !== undefined) {
|
|
100
107
|
if (options.parentId === null) {
|
|
@@ -117,6 +124,7 @@ export class TaxonomyRepository {
|
|
|
117
124
|
.selectAll()
|
|
118
125
|
.where("parent_id", "=", parentId)
|
|
119
126
|
.orderBy("label", "asc")
|
|
127
|
+
.orderBy("id", "asc")
|
|
120
128
|
.execute();
|
|
121
129
|
|
|
122
130
|
return rows.map((row) => this.rowToTaxonomy(row));
|
|
@@ -132,7 +140,10 @@ export class TaxonomyRepository {
|
|
|
132
140
|
const updates: Partial<TaxonomyTable> = {};
|
|
133
141
|
if (input.slug !== undefined) updates.slug = input.slug;
|
|
134
142
|
if (input.label !== undefined) updates.label = input.label;
|
|
135
|
-
if (input.parentId !== undefined)
|
|
143
|
+
if (input.parentId !== undefined) {
|
|
144
|
+
// Defense in depth: empty-string parentId means null (no parent).
|
|
145
|
+
updates.parent_id = input.parentId === "" ? null : input.parentId;
|
|
146
|
+
}
|
|
136
147
|
if (input.data !== undefined) updates.data = JSON.stringify(input.data);
|
|
137
148
|
|
|
138
149
|
if (Object.keys(updates).length > 0) {
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { encodeBase64, decodeBase64 } from "../../utils/base64.js";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Hard cap on cursor length. Cursors we issue are short JSON-in-base64
|
|
5
|
+
* blobs; a real cursor is well under 200 chars. This guards against
|
|
6
|
+
* malicious callers passing megabyte-sized strings to force the base64
|
|
7
|
+
* decoder to allocate (decodeBase64 is O(N) in input size). The MCP and
|
|
8
|
+
* REST schemas also clamp at 2048 — this 4096 cap is a defense-in-depth
|
|
9
|
+
* floor inside the repository helpers.
|
|
10
|
+
*/
|
|
11
|
+
const MAX_CURSOR_LENGTH = 4096;
|
|
12
|
+
|
|
3
13
|
export interface CreateContentInput {
|
|
4
14
|
type: string;
|
|
5
15
|
slug?: string | null;
|
|
@@ -87,17 +97,45 @@ export function encodeCursor(orderValue: string, id: string): string {
|
|
|
87
97
|
return encodeBase64(JSON.stringify({ orderValue, id }));
|
|
88
98
|
}
|
|
89
99
|
|
|
90
|
-
/**
|
|
91
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Thrown when a pagination cursor cannot be decoded.
|
|
102
|
+
*
|
|
103
|
+
* Repository callers should let this propagate; handler catch blocks
|
|
104
|
+
* map it to a structured `INVALID_CURSOR` error so client pagination
|
|
105
|
+
* bugs surface immediately rather than silently re-fetching the first
|
|
106
|
+
* page.
|
|
107
|
+
*/
|
|
108
|
+
export class InvalidCursorError extends Error {
|
|
109
|
+
constructor(cursor: string) {
|
|
110
|
+
const display = cursor.length > 50 ? `${cursor.slice(0, 47)}...` : cursor;
|
|
111
|
+
super(`Invalid pagination cursor: ${display}`);
|
|
112
|
+
this.name = "InvalidCursorError";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Decode a cursor to order value + id.
|
|
118
|
+
*
|
|
119
|
+
* Throws `InvalidCursorError` if the cursor is empty, not valid base64,
|
|
120
|
+
* not valid JSON, or doesn't contain string `orderValue` and `id` fields.
|
|
121
|
+
*/
|
|
122
|
+
export function decodeCursor(cursor: string): { orderValue: string; id: string } {
|
|
123
|
+
if (!cursor) throw new InvalidCursorError(cursor);
|
|
124
|
+
if (cursor.length > MAX_CURSOR_LENGTH) throw new InvalidCursorError(cursor);
|
|
125
|
+
let parsed: unknown;
|
|
92
126
|
try {
|
|
93
|
-
|
|
94
|
-
if (typeof parsed.orderValue === "string" && typeof parsed.id === "string") {
|
|
95
|
-
return parsed;
|
|
96
|
-
}
|
|
97
|
-
return null;
|
|
127
|
+
parsed = JSON.parse(decodeBase64(cursor));
|
|
98
128
|
} catch {
|
|
99
|
-
|
|
129
|
+
throw new InvalidCursorError(cursor);
|
|
130
|
+
}
|
|
131
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
132
|
+
throw new InvalidCursorError(cursor);
|
|
133
|
+
}
|
|
134
|
+
const candidate = parsed as { orderValue?: unknown; id?: unknown };
|
|
135
|
+
if (typeof candidate.orderValue !== "string" || typeof candidate.id !== "string") {
|
|
136
|
+
throw new InvalidCursorError(cursor);
|
|
100
137
|
}
|
|
138
|
+
return { orderValue: candidate.orderValue, id: candidate.id };
|
|
101
139
|
}
|
|
102
140
|
|
|
103
141
|
export interface ContentItem {
|
|
@@ -121,6 +159,17 @@ export interface ContentItem {
|
|
|
121
159
|
translationGroup: string | null;
|
|
122
160
|
/** SEO metadata — only populated for collections with `has_seo` enabled */
|
|
123
161
|
seo?: ContentSeo;
|
|
162
|
+
/**
|
|
163
|
+
* For collections that support `revisions`: when a draft revision exists,
|
|
164
|
+
* `data` reflects the unsaved draft and `liveData` carries the currently-
|
|
165
|
+
* published values. When no draft exists, `liveData` is undefined.
|
|
166
|
+
*
|
|
167
|
+
* Hydrated by `EmDashRuntime.hydrateDraftData()` — repositories themselves
|
|
168
|
+
* never set this field; it's purely a runtime-overlay concept that gives
|
|
169
|
+
* agents a clear picture of "draft vs. live" without re-fetching the
|
|
170
|
+
* revision history.
|
|
171
|
+
*/
|
|
172
|
+
liveData?: Record<string, unknown>;
|
|
124
173
|
}
|
|
125
174
|
|
|
126
175
|
export class EmDashValidationError extends Error {
|
|
@@ -123,14 +123,12 @@ export class UserRepository {
|
|
|
123
123
|
|
|
124
124
|
if (options.cursor) {
|
|
125
125
|
const decoded = decodeCursor(options.cursor);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
eb.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
);
|
|
133
|
-
}
|
|
126
|
+
query = query.where((eb) =>
|
|
127
|
+
eb.or([
|
|
128
|
+
eb("created_at", "<", decoded.orderValue),
|
|
129
|
+
eb.and([eb("created_at", "=", decoded.orderValue), eb("id", "<", decoded.id)]),
|
|
130
|
+
]),
|
|
131
|
+
);
|
|
134
132
|
}
|
|
135
133
|
|
|
136
134
|
const rows = await query.execute();
|
package/src/database/types.ts
CHANGED
|
@@ -466,6 +466,15 @@ export interface NotFoundLogTable {
|
|
|
466
466
|
referrer: string | null;
|
|
467
467
|
user_agent: string | null;
|
|
468
468
|
ip: string | null;
|
|
469
|
+
hits: number;
|
|
470
|
+
/**
|
|
471
|
+
* Migration 035 adds this as a nullable column (SQLite can't add a
|
|
472
|
+
* NOT NULL column with a non-constant default to an existing table).
|
|
473
|
+
* The `log404` upsert always writes a value, so new and updated rows
|
|
474
|
+
* always have one, but existing rows pre-migration were backfilled
|
|
475
|
+
* without a NOT NULL constraint. Typed as nullable to match the schema.
|
|
476
|
+
*/
|
|
477
|
+
last_seen_at: string | null;
|
|
469
478
|
created_at: string;
|
|
470
479
|
}
|
|
471
480
|
|