emdash 0.40.0 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/dist/api/route-utils.mjs +6 -6
  2. package/dist/api/schemas/index.d.mts +1 -1
  3. package/dist/{api-cFFZpyS0.mjs → api-CuyNEHZG.mjs} +3 -3
  4. package/dist/{api-cFFZpyS0.mjs.map → api-CuyNEHZG.mjs.map} +1 -1
  5. package/dist/{apply-CoT603H3.mjs → apply-npY1TAhG.mjs} +237 -34
  6. package/dist/apply-npY1TAhG.mjs.map +1 -0
  7. package/dist/astro/index.d.mts +3 -3
  8. package/dist/astro/index.mjs +9 -9
  9. package/dist/astro/index.mjs.map +1 -1
  10. package/dist/astro/middleware/auth.d.mts +3 -3
  11. package/dist/astro/middleware/redirect.mjs +1 -1
  12. package/dist/astro/middleware.d.mts +3 -3
  13. package/dist/astro/middleware.d.mts.map +1 -1
  14. package/dist/astro/middleware.mjs +19 -18
  15. package/dist/astro/middleware.mjs.map +1 -1
  16. package/dist/astro/routes/api/admin/media-usage/progress.mjs +2 -2
  17. package/dist/astro/routes/api/admin/media-usage/repair.mjs +2 -2
  18. package/dist/astro/routes/api/admin/plugins/_id_/index.mjs +8 -8
  19. package/dist/astro/routes/api/admin/plugins/_id_/settings.mjs +8 -8
  20. package/dist/astro/routes/api/admin/plugins/_id_/uninstall.mjs +8 -8
  21. package/dist/astro/routes/api/admin/plugins/_id_/update.mjs +11 -11
  22. package/dist/astro/routes/api/admin/plugins/index.mjs +8 -8
  23. package/dist/astro/routes/api/admin/plugins/marketplace/_id_/index.mjs +8 -8
  24. package/dist/astro/routes/api/admin/plugins/marketplace/_id_/install.mjs +11 -11
  25. package/dist/astro/routes/api/admin/plugins/marketplace/index.mjs +8 -8
  26. package/dist/astro/routes/api/admin/plugins/registry/_id_/uninstall.mjs +8 -8
  27. package/dist/astro/routes/api/admin/plugins/registry/_id_/update.mjs +12 -12
  28. package/dist/astro/routes/api/admin/plugins/registry/install.mjs +9 -9
  29. package/dist/astro/routes/api/admin/plugins/registry/verify.mjs +9 -9
  30. package/dist/astro/routes/api/admin/plugins/updates.mjs +8 -8
  31. package/dist/astro/routes/api/admin/themes/marketplace/_id_/index.mjs +8 -8
  32. package/dist/astro/routes/api/admin/themes/marketplace/index.mjs +8 -8
  33. package/dist/astro/routes/api/admin/transfer/approvals/_id_/approve.mjs +3 -3
  34. package/dist/astro/routes/api/admin/transfer/approvals/_id_/deny.mjs +3 -3
  35. package/dist/astro/routes/api/admin/transfer/approvals/index.mjs +3 -3
  36. package/dist/astro/routes/api/admin/transfer/capabilities.mjs +3 -3
  37. package/dist/astro/routes/api/admin/transfer/exports/_id_/advance.mjs +3 -3
  38. package/dist/astro/routes/api/admin/transfer/exports/_id_/archive.mjs +3 -3
  39. package/dist/astro/routes/api/admin/transfer/exports/_id_/files/_...path_.mjs +3 -3
  40. package/dist/astro/routes/api/admin/transfer/exports/_id_/index.mjs +3 -3
  41. package/dist/astro/routes/api/admin/transfer/exports/_id_/manifest.mjs +3 -3
  42. package/dist/astro/routes/api/admin/transfer/exports/index.mjs +3 -3
  43. package/dist/astro/routes/api/admin/transfer/imports/_id_/abandon.mjs +3 -3
  44. package/dist/astro/routes/api/admin/transfer/imports/_id_/advance.mjs +3 -3
  45. package/dist/astro/routes/api/admin/transfer/imports/_id_/analyze.mjs +3 -3
  46. package/dist/astro/routes/api/admin/transfer/imports/_id_/cancel.mjs +3 -3
  47. package/dist/astro/routes/api/admin/transfer/imports/_id_/execute.mjs +3 -3
  48. package/dist/astro/routes/api/admin/transfer/imports/_id_/files/_...path_.mjs +3 -3
  49. package/dist/astro/routes/api/admin/transfer/imports/_id_/index.mjs +3 -3
  50. package/dist/astro/routes/api/admin/transfer/imports/_id_/missing.mjs +3 -3
  51. package/dist/astro/routes/api/admin/transfer/imports/_id_/plan.mjs +3 -3
  52. package/dist/astro/routes/api/admin/transfer/imports/_id_/receipt.mjs +3 -3
  53. package/dist/astro/routes/api/admin/transfer/imports/index.mjs +3 -3
  54. package/dist/astro/routes/api/dashboard.mjs +2 -2
  55. package/dist/astro/routes/api/health.mjs +1 -1
  56. package/dist/astro/routes/api/import/probe.mjs +3 -3
  57. package/dist/astro/routes/api/import/wordpress/execute.d.mts +3 -3
  58. package/dist/astro/routes/api/import/wordpress/media.d.mts +3 -3
  59. package/dist/astro/routes/api/import/wordpress/rewrite-urls.d.mts +3 -3
  60. package/dist/astro/routes/api/import/wordpress-plugin/analyze.mjs +3 -3
  61. package/dist/astro/routes/api/import/wordpress-plugin/execute.d.mts +3 -3
  62. package/dist/astro/routes/api/import/wordpress-plugin/execute.mjs +3 -3
  63. package/dist/astro/routes/api/manifest.mjs +1 -1
  64. package/dist/astro/routes/api/mcp.mjs +13 -33
  65. package/dist/astro/routes/api/mcp.mjs.map +1 -1
  66. package/dist/astro/routes/api/media/_id_/confirm.mjs +1 -1
  67. package/dist/astro/routes/api/media/_id_/upload.mjs +1 -1
  68. package/dist/astro/routes/api/media/_id_/usage.mjs +2 -2
  69. package/dist/astro/routes/api/media/_id_.mjs +2 -2
  70. package/dist/astro/routes/api/media/asset/_id_/_filename_.mjs +1 -1
  71. package/dist/astro/routes/api/media/upload-url.mjs +1 -1
  72. package/dist/astro/routes/api/media.mjs +2 -2
  73. package/dist/astro/routes/api/redirects/404s/index.mjs +2 -2
  74. package/dist/astro/routes/api/redirects/404s/summary.mjs +2 -2
  75. package/dist/astro/routes/api/redirects/_id_.mjs +2 -2
  76. package/dist/astro/routes/api/redirects/index.mjs +2 -2
  77. package/dist/astro/routes/api/schema/block-types/_slug_/index.mjs +8 -8
  78. package/dist/astro/routes/api/schema/block-types/_slug_/versions/_version_/activate.mjs +8 -8
  79. package/dist/astro/routes/api/schema/block-types/index.mjs +8 -8
  80. package/dist/astro/routes/api/schema/collections/_slug_/fields/_fieldSlug_.mjs +8 -8
  81. package/dist/astro/routes/api/schema/collections/_slug_/fields/index.mjs +8 -8
  82. package/dist/astro/routes/api/schema/collections/_slug_/fields/reorder.mjs +8 -8
  83. package/dist/astro/routes/api/schema/collections/_slug_/index.mjs +8 -8
  84. package/dist/astro/routes/api/schema/collections/index.mjs +8 -8
  85. package/dist/astro/routes/api/schema/collections/reorder.mjs +8 -8
  86. package/dist/astro/routes/api/schema/orphans/_slug_.mjs +8 -8
  87. package/dist/astro/routes/api/schema/orphans/index.mjs +8 -8
  88. package/dist/astro/routes/api/settings/backups/archives/_name_.mjs +2 -2
  89. package/dist/astro/routes/api/settings/backups/archives/index.mjs +2 -2
  90. package/dist/astro/routes/api/settings/backups/export.mjs +2 -2
  91. package/dist/astro/routes/api/settings/backups/index.mjs +2 -2
  92. package/dist/astro/routes/api/settings.mjs +3 -3
  93. package/dist/astro/routes/api/setup/dev-bypass.mjs +4 -4
  94. package/dist/astro/routes/api/setup/index.d.mts.map +1 -1
  95. package/dist/astro/routes/api/setup/index.mjs +35 -10
  96. package/dist/astro/routes/api/setup/index.mjs.map +1 -1
  97. package/dist/astro/routes/api/snapshot.mjs +2 -2
  98. package/dist/astro/routes/api/snapshot.mjs.map +1 -1
  99. package/dist/astro/routes/robots.txt.mjs +2 -2
  100. package/dist/astro/routes/sitemap-_collection_.xml.mjs +2 -2
  101. package/dist/astro/routes/sitemap.xml.mjs +2 -2
  102. package/dist/astro/types.d.mts +7 -3
  103. package/dist/astro/types.d.mts.map +1 -1
  104. package/dist/{backup-CBu0LrXf.mjs → backup-BQRj3afm.mjs} +3 -3
  105. package/dist/{backup-CBu0LrXf.mjs.map → backup-BQRj3afm.mjs.map} +1 -1
  106. package/dist/cli/index.mjs +6 -6
  107. package/dist/client/index.d.mts +14 -14
  108. package/dist/{config-5ii2haYu.d.mts → config-BjEm_3xL.d.mts} +25 -16
  109. package/dist/config-BjEm_3xL.d.mts.map +1 -0
  110. package/dist/{context-BeGccdUX.mjs → context-BV_7t-Ua.mjs} +6 -6
  111. package/dist/{context-BeGccdUX.mjs.map → context-BV_7t-Ua.mjs.map} +1 -1
  112. package/dist/{dashboard-DjrB-xxZ.mjs → dashboard-BP-iJqpd.mjs} +2 -2
  113. package/dist/{dashboard-DjrB-xxZ.mjs.map → dashboard-BP-iJqpd.mjs.map} +1 -1
  114. package/dist/db/libsql-migrations.mjs +2 -2
  115. package/dist/db/postgres-migrations.mjs +2 -2
  116. package/dist/db/sqlite-migrations.mjs +2 -2
  117. package/dist/{direct-executor-BmuFJJZV.mjs → direct-executor-D2lNadwo.mjs} +2 -2
  118. package/dist/{direct-executor-BmuFJJZV.mjs.map → direct-executor-D2lNadwo.mjs.map} +1 -1
  119. package/dist/{emdash-runtime-DAjghyEG.mjs → emdash-runtime-CtfnZmPi.mjs} +56 -56
  120. package/dist/emdash-runtime-CtfnZmPi.mjs.map +1 -0
  121. package/dist/{entry-lock-DVhCt49A.d.mts → entry-lock-Cf5OoIgN.d.mts} +18 -18
  122. package/dist/{entry-lock-DVhCt49A.d.mts.map → entry-lock-Cf5OoIgN.d.mts.map} +1 -1
  123. package/dist/{hreflang-DMFBL2eb.mjs → hreflang-CbVI4paR.mjs} +2 -2
  124. package/dist/{hreflang-DMFBL2eb.mjs.map → hreflang-CbVI4paR.mjs.map} +1 -1
  125. package/dist/{identity-IyNWwXr6.mjs → identity-Be5kzutJ.mjs} +2 -2
  126. package/dist/{identity-IyNWwXr6.mjs.map → identity-Be5kzutJ.mjs.map} +1 -1
  127. package/dist/{import-C3JZplWq.mjs → import-YzfKCaco.mjs} +2 -2
  128. package/dist/{import-C3JZplWq.mjs.map → import-YzfKCaco.mjs.map} +1 -1
  129. package/dist/index.d.mts +4 -4
  130. package/dist/index.mjs +16 -16
  131. package/dist/{manifest-builder-B8zR-73J.mjs → manifest-builder-BmSAOOEF.mjs} +2 -2
  132. package/dist/{manifest-builder-B8zR-73J.mjs.map → manifest-builder-BmSAOOEF.mjs.map} +1 -1
  133. package/dist/media/local-runtime.d.mts +3 -3
  134. package/dist/media/local-runtime.mjs +3 -3
  135. package/dist/{media-Qp5S3eH3.mjs → media-CPXTbb1R.mjs} +20 -2
  136. package/dist/media-CPXTbb1R.mjs.map +1 -0
  137. package/dist/{media-h7tOiDLj.mjs → media-JXRWDhw3.mjs} +77 -4
  138. package/dist/media-JXRWDhw3.mjs.map +1 -0
  139. package/dist/{media-usage-My-Vi-ex.mjs → media-usage-De-zeR9E.mjs} +2 -2
  140. package/dist/{media-usage-My-Vi-ex.mjs.map → media-usage-De-zeR9E.mjs.map} +1 -1
  141. package/dist/{menus-TsSGmSR4.mjs → menus-Cm68rsAq.mjs} +4 -4
  142. package/dist/{menus-TsSGmSR4.mjs.map → menus-Cm68rsAq.mjs.map} +1 -1
  143. package/dist/migrations/index.mjs +2 -2
  144. package/dist/page/index.mjs +2 -2
  145. package/dist/plugin-test-runtime.d.mts +3 -3
  146. package/dist/plugin-test-runtime.mjs +18 -18
  147. package/dist/plugin-utils.d.mts +3 -3
  148. package/dist/plugins/adapt-sandbox-entry.d.mts +3 -3
  149. package/dist/plugins/host.d.mts +1 -1
  150. package/dist/plugins/host.mjs +6 -6
  151. package/dist/{redirect-iJf5fltD.mjs → redirect-B29UjdUn.mjs} +10 -14
  152. package/dist/redirect-B29UjdUn.mjs.map +1 -0
  153. package/dist/{redirects-CYu1u1aK.mjs → redirects-Ca2MPfEG.mjs} +2 -2
  154. package/dist/{redirects-CYu1u1aK.mjs.map → redirects-Ca2MPfEG.mjs.map} +1 -1
  155. package/dist/{routes-CUsM40Cc.mjs → routes-vrR95WR6.mjs} +2 -2
  156. package/dist/{routes-CUsM40Cc.mjs.map → routes-vrR95WR6.mjs.map} +1 -1
  157. package/dist/runtime.d.mts +3 -3
  158. package/dist/seed/index.mjs +4 -4
  159. package/dist/seo/index.mjs +3 -3
  160. package/dist/{settings-CgjRWGW9.mjs → settings-B4vDl1fK.mjs} +2 -2
  161. package/dist/{settings-CgjRWGW9.mjs.map → settings-B4vDl1fK.mjs.map} +1 -1
  162. package/dist/{settings-2xNYr90o.mjs → settings-Dyj6v8C4.mjs} +2 -2
  163. package/dist/{settings-2xNYr90o.mjs.map → settings-Dyj6v8C4.mjs.map} +1 -1
  164. package/dist/{snapshot-CugwUqKQ.mjs → snapshot-PawoX-WG.mjs} +180 -3
  165. package/dist/snapshot-PawoX-WG.mjs.map +1 -0
  166. package/dist/{storage-query-Bdv7A62k.d.mts → storage-query-FXJH5pI8.d.mts} +6 -1
  167. package/dist/storage-query-FXJH5pI8.d.mts.map +1 -0
  168. package/dist/{transfer-BhUCrgDc.mjs → transfer-DgeCvHnj.mjs} +4 -4
  169. package/dist/{transfer-BhUCrgDc.mjs.map → transfer-DgeCvHnj.mjs.map} +1 -1
  170. package/dist/validate-CM79QXYE.d.mts.map +1 -1
  171. package/dist/version-DXsxRrob.mjs +7 -0
  172. package/dist/{version-DUQDHgrf.mjs.map → version-DXsxRrob.mjs.map} +1 -1
  173. package/package.json +5 -5
  174. package/src/api/handlers/index.ts +1 -0
  175. package/src/api/handlers/media-upload.ts +17 -71
  176. package/src/api/handlers/media.ts +97 -3
  177. package/src/api/handlers/snapshot.ts +257 -3
  178. package/src/astro/integration/vite-config.ts +10 -4
  179. package/src/astro/middleware.ts +1 -0
  180. package/src/astro/routes/api/setup/index.ts +66 -23
  181. package/src/astro/routes/api/snapshot.ts +2 -1
  182. package/src/astro/types.ts +5 -0
  183. package/src/cleanup.ts +10 -0
  184. package/src/components/Columns.astro +3 -1
  185. package/src/components/Cover.astro +3 -1
  186. package/src/database/repositories/media.ts +40 -1
  187. package/src/database/repositories/redirect.ts +17 -34
  188. package/src/emdash-runtime.ts +28 -0
  189. package/src/index.ts +1 -0
  190. package/src/mcp/server.ts +13 -48
  191. package/src/seed/apply.ts +322 -41
  192. package/dist/apply-CoT603H3.mjs.map +0 -1
  193. package/dist/config-5ii2haYu.d.mts.map +0 -1
  194. package/dist/emdash-runtime-DAjghyEG.mjs.map +0 -1
  195. package/dist/media-Qp5S3eH3.mjs.map +0 -1
  196. package/dist/media-h7tOiDLj.mjs.map +0 -1
  197. package/dist/redirect-iJf5fltD.mjs.map +0 -1
  198. package/dist/snapshot-CugwUqKQ.mjs.map +0 -1
  199. package/dist/storage-query-Bdv7A62k.d.mts.map +0 -1
  200. package/dist/version-DUQDHgrf.mjs +0 -7
@@ -10,14 +10,25 @@ export const prerender = false;
10
10
 
11
11
  import { apiError, apiSuccess, handleError } from "#api/error.js";
12
12
  import { isParseError, parseBody } from "#api/parse.js";
13
- import { getPublicOrigin } from "#api/public-url.js";
13
+ import { getConfiguredOrigin } from "#api/public-url.js";
14
14
  import { setupBody } from "#api/schemas.js";
15
15
  import { getAuthMode } from "#auth/mode.js";
16
16
  import { OptionsRepository } from "#db/repositories/options.js";
17
- import { applySeed } from "#seed/apply.js";
17
+ import { applySeedWithinBudget, type SeedApplyBudget } from "#seed/apply.js";
18
18
  import { loadSeed } from "#seed/load.js";
19
19
  import { validateSeed } from "#seed/validate.js";
20
20
 
21
+ /**
22
+ * What one setup request may spend on the seed before the rest continues in
23
+ * the next request. Cloudflare Workers Free allows 1,000 calls to D1, KV and R2
24
+ * and 50 external fetches per request. A `$media` download takes at least three
25
+ * fetches (two DNS-over-HTTPS lookups in `ssrfSafeFetch`, then the file) and
26
+ * three more per redirect. The margin covers a redirect per download, the entry
27
+ * that crosses the budget with its images, the phases after content and the
28
+ * object-cache writes at the end.
29
+ */
30
+ const SEED_BUDGET_PER_REQUEST: SeedApplyBudget = { queries: 500, mediaDownloads: 5 };
31
+
21
32
  export const POST: APIRoute = async ({ request, url, locals }) => {
22
33
  const { emdash } = locals;
23
34
 
@@ -40,6 +51,19 @@ export const POST: APIRoute = async ({ request, url, locals }) => {
40
51
  // Options table doesn't exist yet — first-ever setup, allow it
41
52
  }
42
53
 
54
+ const configuredSiteUrl = getConfiguredOrigin(emdash.config);
55
+ const loopbackHost =
56
+ url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
57
+ const siteUrl =
58
+ configuredSiteUrl ?? (import.meta.env.DEV && loopbackHost ? url.origin : undefined);
59
+ if (!siteUrl) {
60
+ return apiError(
61
+ "SITE_URL_REQUIRED",
62
+ "Set siteUrl or EMDASH_SITE_URL before running production setup",
63
+ 500,
64
+ );
65
+ }
66
+
43
67
  // Parse request body
44
68
  const body = await parseBody(request, setupBody);
45
69
  if (isParseError(body)) return body;
@@ -60,16 +84,22 @@ export const POST: APIRoute = async ({ request, url, locals }) => {
60
84
  return apiError("INVALID_SEED", `Invalid seed file: ${validation.errors.join(", ")}`, 400);
61
85
  }
62
86
 
63
- let result;
87
+ let seeded;
64
88
  try {
65
- result = await applySeed(emdash.db, seed, {
66
- includeContent: body.includeContent,
67
- onConflict: "skip",
68
- storage: emdash.storage ?? undefined,
69
- });
89
+ seeded = await applySeedWithinBudget(
90
+ emdash.db,
91
+ seed,
92
+ {
93
+ includeContent: body.includeContent,
94
+ onConflict: "skip",
95
+ storage: emdash.storage ?? undefined,
96
+ },
97
+ SEED_BUDGET_PER_REQUEST,
98
+ );
70
99
  } catch (error) {
71
100
  return handleError(error, "Failed to apply seed", "SEED_ERROR");
72
101
  }
102
+ const { result, complete: seedComplete, progress: seedProgress } = seeded;
73
103
 
74
104
  // Store setup state
75
105
  // In external auth mode, mark setup complete immediately (first user to login becomes admin)
@@ -85,35 +115,48 @@ export const POST: APIRoute = async ({ request, url, locals }) => {
85
115
  // observe an empty value and race to write. A spoofed Host header
86
116
  // on a later call during the wizard window must not be able to
87
117
  // replace the first value.
88
- const siteUrl = getPublicOrigin(url, emdash.config);
89
118
  await options.setIfAbsent("emdash:site_url", siteUrl);
90
119
 
91
- if (useExternalAuth) {
92
- // External auth mode: mark setup complete now
93
- // First user to log in via external provider will become admin
94
- await options.set("emdash:setup_complete", true);
95
- await options.set("emdash:site_title", body.title);
96
- if (body.tagline) {
97
- await options.set("emdash:site_tagline", body.tagline);
120
+ if (seedComplete) {
121
+ if (useExternalAuth) {
122
+ // External auth mode: mark setup complete now
123
+ // First user to log in via external provider will become admin
124
+ await options.set("emdash:setup_complete", true);
125
+ await options.set("emdash:site_title", body.title);
126
+ if (body.tagline) {
127
+ await options.set("emdash:site_tagline", body.tagline);
128
+ }
129
+ } else {
130
+ // Passkey/provider mode: store state for next step (admin creation)
131
+ await options.set("emdash:setup_state", {
132
+ step: "site_complete",
133
+ title: body.title,
134
+ tagline: body.tagline,
135
+ });
98
136
  }
99
- } else {
100
- // Passkey/provider mode: store state for next step (admin creation)
101
- await options.set("emdash:setup_state", {
102
- step: "site_complete",
103
- title: body.title,
104
- tagline: body.tagline,
105
- });
106
137
  }
107
138
  } catch (error) {
108
139
  console.error("Failed to save setup state:", error);
109
140
  // Non-fatal - continue anyway
110
141
  }
111
142
 
143
+ if (!seedComplete) {
144
+ // The wizard posts again; items already created are skipped.
145
+ return apiSuccess({
146
+ success: true,
147
+ setupComplete: false,
148
+ seedComplete: false,
149
+ seedProgress,
150
+ result,
151
+ });
152
+ }
153
+
112
154
  // Return success with result
113
155
  return apiSuccess({
114
156
  success: true,
115
157
  // In external auth mode, setup is complete - redirect to admin
116
158
  setupComplete: useExternalAuth,
159
+ seedComplete: true,
117
160
  result,
118
161
  });
119
162
  } catch (error) {
@@ -91,7 +91,8 @@ export const GET: APIRoute = async ({ request, locals, url, session }) => {
91
91
  }
92
92
 
93
93
  try {
94
- const includeDrafts = url.searchParams.get("drafts") === "true";
94
+ // A preview signature covers published content only.
95
+ const includeDrafts = !authorized && url.searchParams.get("drafts") === "true";
95
96
  const snapshot = await generateSnapshot(emdash.db, {
96
97
  includeDrafts,
97
98
  origin: getPublicOrigin(url, emdash.config),
@@ -466,6 +466,11 @@ export interface EmDashHandlers {
466
466
  folderId?: string | null;
467
467
  }) => Promise<HandlerResponse>;
468
468
 
469
+ handleMediaRegisterUpload: (input: {
470
+ storageKey: string;
471
+ authorId?: string;
472
+ }) => Promise<HandlerResponse>;
473
+
469
474
  handleMediaUpdate: (
470
475
  id: string,
471
476
  input: {
package/src/cleanup.ts CHANGED
@@ -14,6 +14,7 @@ import type { Kysely } from "kysely";
14
14
 
15
15
  import { cleanupExpiredChallenges } from "./auth/challenge-store.js";
16
16
  import { MediaRepository } from "./database/repositories/media.js";
17
+ import { RedirectRepository } from "./database/repositories/redirect.js";
17
18
  import { RevisionRepository } from "./database/repositories/revision.js";
18
19
  import type { Database } from "./database/types.js";
19
20
  import { removeUploadAttempt } from "./media/upload-attempts.js";
@@ -32,6 +33,8 @@ export interface CleanupResult {
32
33
  uploadAttempts: number;
33
34
  revisionsPruned: number;
34
35
  mediaUsage: number;
36
+ /** Oldest 404-log rows deleted to restore the hard cap. */
37
+ notFoundLog: number;
35
38
  /** Transfer operations whose staging area was collected. */
36
39
  transferStaging: number;
37
40
  }
@@ -62,6 +65,7 @@ export async function runSystemCleanup(
62
65
  uploadAttempts: -1,
63
66
  revisionsPruned: -1,
64
67
  mediaUsage: -1,
68
+ notFoundLog: -1,
65
69
  transferStaging: -1,
66
70
  };
67
71
 
@@ -145,6 +149,12 @@ export async function runSystemCleanup(
145
149
  console.error("[cleanup] Failed to clean media usage:", error);
146
150
  }
147
151
 
152
+ try {
153
+ result.notFoundLog = await new RedirectRepository(db).cleanup404Log();
154
+ } catch (error) {
155
+ console.error("[cleanup] Failed to cap the 404 log:", error);
156
+ }
157
+
148
158
  if (storage) {
149
159
  try {
150
160
  const { collectTransferStaging } = await import("./transfer/gc.js");
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import { PortableText } from "astro-portabletext";
8
8
 
9
+ import { emdashMarkComponents } from "./marks.js";
10
+
9
11
  export interface Props {
10
12
  node: {
11
13
  _type: "columns";
@@ -34,7 +36,7 @@ if (!columns.length) {
34
36
  class="emdash-column"
35
37
  style={column.width ? `flex-basis: ${column.width}` : undefined}
36
38
  >
37
- <PortableText value={column.content} />
39
+ <PortableText value={column.content} components={{ mark: emdashMarkComponents }} />
38
40
  </div>
39
41
  ))
40
42
  }
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import { PortableText } from "astro-portabletext";
8
8
 
9
+ import { emdashMarkComponents } from "./marks.js";
10
+
9
11
  export interface Props {
10
12
  node: {
11
13
  _type: "cover";
@@ -61,7 +63,7 @@ const overlayStyle = overlayColor
61
63
  }
62
64
  {hasBackground && <div class="emdash-cover__overlay" style={overlayStyle} />}
63
65
  <div class="emdash-cover__content">
64
- <PortableText value={content} />
66
+ <PortableText value={content} components={{ mark: emdashMarkComponents }} />
65
67
  </div>
66
68
  </div>
67
69
 
@@ -8,6 +8,7 @@ import {
8
8
  import { ulid } from "ulidx";
9
9
 
10
10
  import { normalizeFocalPoint, type FocalPointUpdate } from "../../media/focal-point.js";
11
+ import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js";
11
12
  import type { Database, MediaRow } from "../types.js";
12
13
  import type { FindManyResult } from "./types.js";
13
14
  import { encodeCursor, decodeCursor } from "./types.js";
@@ -393,6 +394,20 @@ export class MediaRepository {
393
394
  return row ? this.rowToItem(row) : null;
394
395
  }
395
396
 
397
+ /**
398
+ * Find the pending media row minted for a signed upload URL.
399
+ */
400
+ async findPendingByStorageKey(storageKey: string): Promise<MediaItem | null> {
401
+ const row = await this.db
402
+ .selectFrom("media")
403
+ .selectAll()
404
+ .where("storage_key", "=", storageKey)
405
+ .where("status", "=", "pending")
406
+ .executeTakeFirst();
407
+
408
+ return row ? this.rowToItem(row) : null;
409
+ }
410
+
396
411
  /**
397
412
  * Find media by ID
398
413
  */
@@ -614,6 +629,15 @@ export class MediaRepository {
614
629
  return null;
615
630
  }
616
631
 
632
+ async isStorageKeyReferenced(storageKey: string): Promise<boolean> {
633
+ const row = await this.db
634
+ .selectFrom("media")
635
+ .select("id")
636
+ .where("storage_key", "=", storageKey)
637
+ .executeTakeFirst();
638
+ return row !== undefined;
639
+ }
640
+
617
641
  async delete(id: string): Promise<boolean> {
618
642
  return (await this.deleteWithStorageKey(id)) !== null;
619
643
  }
@@ -682,7 +706,22 @@ export class MediaRepository {
682
706
  .returning("storage_key")
683
707
  .execute();
684
708
 
685
- return rows.map((r) => r.storage_key);
709
+ const keys = rows.map((r) => r.storage_key);
710
+ if (keys.length === 0) return keys;
711
+
712
+ // A stored object may still back another media row (for example after a
713
+ // legacy duplicate registration); never hand such a key to storage deletion.
714
+ const stillReferenced = new Set<string>();
715
+ for (const batch of chunks(keys, SQL_BATCH_SIZE)) {
716
+ const refs = await this.db
717
+ .selectFrom("media")
718
+ .select("storage_key")
719
+ .where("storage_key", "in", batch)
720
+ .execute();
721
+ for (const ref of refs) stillReferenced.add(ref.storage_key);
722
+ }
723
+
724
+ return keys.filter((key) => !stillReferenced.has(key));
686
725
  }
687
726
 
688
727
  /**
@@ -17,9 +17,9 @@ import { encodeCursor, decodeCursor, type FindManyResult } from "./types.js";
17
17
  // ---------------------------------------------------------------------------
18
18
 
19
19
  /**
20
- * Hard cap on rows stored in `_emdash_404_log`. When exceeded, the oldest
21
- * rows (by `last_seen_at`) are evicted on insert. Prevents an unauthenticated
22
- * attacker from growing the table without bound by requesting unique URLs.
20
+ * Hard cap on rows stored in `_emdash_404_log`. Scheduled maintenance evicts
21
+ * the oldest rows by `last_seen_at` without adding a read-amplifying count to
22
+ * the anonymous request path.
23
23
  */
24
24
  export const MAX_404_LOG_ROWS = 10_000;
25
25
 
@@ -646,7 +646,7 @@ export class RedirectRepository {
646
646
  // under concurrency: two requests for the same new path can't both
647
647
  // insert — the second one hits the conflict branch and increments
648
648
  // hits instead of failing with a uniqueness error.
649
- const result = await this.db
649
+ await this.db
650
650
  .insertInto("_emdash_404_log")
651
651
  .values({
652
652
  id,
@@ -667,14 +667,7 @@ export class RedirectRepository {
667
667
  ip,
668
668
  }),
669
669
  )
670
- .returning("id")
671
- .executeTakeFirst();
672
-
673
- // The conflict branch only updates existing rows, so repeat hits
674
- // cannot grow the table. Only enforce the row cap when we actually
675
- // inserted a new path.
676
- if (result?.id !== id) return;
677
- await this.enforce404Cap();
670
+ .execute();
678
671
  }
679
672
 
680
673
  /**
@@ -682,36 +675,26 @@ export class RedirectRepository {
682
675
  * MAX_404_LOG_ROWS. "Oldest" is by `last_seen_at`, so a path that keeps
683
676
  * getting hit stays in the table even if it was first seen long ago.
684
677
  *
685
- * Private — called by `log404` only after a new path was inserted.
678
+ * Called by scheduled system cleanup, never by the anonymous request path.
686
679
  */
687
- private async enforce404Cap(): Promise<void> {
688
- const countRow = await this.db
689
- .selectFrom("_emdash_404_log")
690
- .select((eb) => eb.fn.countAll<number>().as("c"))
691
- .executeTakeFirst();
692
- const count = Number(countRow?.c ?? 0);
693
- if (count <= MAX_404_LOG_ROWS) return;
694
-
695
- const excess = count - MAX_404_LOG_ROWS;
696
-
697
- // Evict the oldest rows in a single SQL statement. Using a subquery
698
- // (rather than materialising the victim IDs in JS and passing them
699
- // back as bind parameters) keeps the statement bounded regardless of
700
- // how far over cap the table is — important for existing installs
701
- // that crossed the threshold before this cap was introduced.
702
- await this.db
680
+ async cleanup404Log(): Promise<number> {
681
+ // Keep the newest rows in one statement. Deriving the victims inside the
682
+ // DELETE makes overlapping cleanup runs idempotent: each statement
683
+ // evaluates the current newest set instead of acting on a stale count.
684
+ const result = await this.db
703
685
  .deleteFrom("_emdash_404_log")
704
686
  .where(
705
687
  "id",
706
- "in",
688
+ "not in",
707
689
  this.db
708
690
  .selectFrom("_emdash_404_log")
709
691
  .select("id")
710
- .orderBy("last_seen_at", "asc")
711
- .orderBy("id", "asc")
712
- .limit(excess),
692
+ .orderBy("last_seen_at", "desc")
693
+ .orderBy("id", "desc")
694
+ .limit(MAX_404_LOG_ROWS),
713
695
  )
714
- .execute();
696
+ .executeTakeFirst();
697
+ return Number(result.numDeletedRows);
715
698
  }
716
699
 
717
700
  async find404s(opts: {
@@ -226,6 +226,7 @@ import {
226
226
  handleMediaList,
227
227
  handleMediaGet,
228
228
  handleMediaCreate,
229
+ handleMediaRegisterUpload,
229
230
  handleMediaUpdate,
230
231
  handleMediaReplaceMetadata,
231
232
  handleMediaDelete,
@@ -4572,6 +4573,33 @@ export class EmDashRuntime {
4572
4573
  return result;
4573
4574
  }
4574
4575
 
4576
+ async handleMediaRegisterUpload(input: { storageKey: string; authorId?: string }) {
4577
+ if (!this.storage) {
4578
+ return {
4579
+ success: false as const,
4580
+ error: { code: "NO_STORAGE", message: "Storage not configured" },
4581
+ };
4582
+ }
4583
+ const result = await handleMediaRegisterUpload(this.db, this.storage, input);
4584
+
4585
+ if (result.success && this.hooks.hasHooks("media:afterUpload")) {
4586
+ const item = result.data.item;
4587
+ const mediaItem: MediaItem = {
4588
+ id: item.id,
4589
+ filename: item.filename,
4590
+ mimeType: item.mimeType,
4591
+ size: item.size,
4592
+ url: `/media/${item.id}/${item.filename}`,
4593
+ createdAt: item.createdAt,
4594
+ };
4595
+ this.hooks
4596
+ .runMediaAfterUpload(mediaItem)
4597
+ .catch((err) => console.error("EmDash afterUpload hook error:", err));
4598
+ }
4599
+
4600
+ return result;
4601
+ }
4602
+
4575
4603
  async handleMediaCreate(input: {
4576
4604
  filename: string;
4577
4605
  mimeType: string;
package/src/index.ts CHANGED
@@ -88,6 +88,7 @@ export {
88
88
  handleMediaList,
89
89
  handleMediaGet,
90
90
  handleMediaCreate,
91
+ handleMediaRegisterUpload,
91
92
  handleMediaUpdate,
92
93
  handleMediaReplaceMetadata,
93
94
  handleMediaDelete,
package/src/mcp/server.ts CHANGED
@@ -2547,27 +2547,15 @@ export function createMcpServer(
2547
2547
  server.registerTool(
2548
2548
  "media_create",
2549
2549
  {
2550
- title: "Register Uploaded Media",
2551
- description:
2552
- "Register a media file that has already been uploaded to storage. The " +
2553
- "caller is responsible for placing the file at `storageKey` (typically " +
2554
- "using a signed upload URL obtained from the admin UI or a separate API). " +
2555
- "This tool persists the metadata record so the file is discoverable via " +
2556
- "media_list / media_get and can be referenced by content. To upload the " +
2557
- "file itself, use media_upload (base64 data or a public URL) instead.",
2550
+ title: "Confirm Signed Media Upload",
2551
+ description:
2552
+ "Confirm a file after uploading it with a signed URL obtained from " +
2553
+ "POST /_emdash/api/media/upload-url. `storageKey` must be the key returned " +
2554
+ "for the same user. Confirmation checks that the stored file exists and " +
2555
+ "matches the size fixed when the URL was issued, then makes it available " +
2556
+ "through media_list / media_get. To upload through MCP, use media_upload instead.",
2558
2557
  inputSchema: z.object({
2559
- filename: z.string().describe("Original filename (e.g. 'logo.png')"),
2560
- mimeType: z.string().describe("MIME type (e.g. 'image/png')"),
2561
- storageKey: z.string().describe("Storage path/key the file was uploaded to"),
2562
- size: z.number().int().nonnegative().optional().describe("File size in bytes"),
2563
- width: z.number().int().positive().optional().describe("Image width in pixels"),
2564
- height: z.number().int().positive().optional().describe("Image height in pixels"),
2565
- contentHash: z.string().optional().describe("Hash of the file contents (for dedupe)"),
2566
- blurhash: z.string().optional().describe("Blurhash for image placeholders"),
2567
- dominantColor: z
2568
- .string()
2569
- .optional()
2570
- .describe("Hex color string for the image's dominant color"),
2558
+ storageKey: z.string().describe("Storage key returned by the signed upload URL request"),
2571
2559
  }),
2572
2560
  },
2573
2561
  async (args, extra) => {
@@ -2575,16 +2563,8 @@ export function createMcpServer(
2575
2563
  requireRole(extra, Role.AUTHOR);
2576
2564
  const { emdash, userId } = getExtra(extra);
2577
2565
  return unwrap(
2578
- await emdash.handleMediaCreate({
2579
- filename: args.filename,
2580
- mimeType: args.mimeType,
2566
+ await emdash.handleMediaRegisterUpload({
2581
2567
  storageKey: args.storageKey,
2582
- size: args.size,
2583
- width: args.width,
2584
- height: args.height,
2585
- contentHash: args.contentHash,
2586
- blurhash: args.blurhash,
2587
- dominantColor: args.dominantColor,
2588
2568
  authorId: userId,
2589
2569
  }),
2590
2570
  );
@@ -2596,34 +2576,20 @@ export function createMcpServer(
2596
2576
  {
2597
2577
  title: "Upload Media",
2598
2578
  description:
2599
- "Upload a media file from base64-encoded data or an external URL and " +
2579
+ "Upload a media file from base64-encoded data and " +
2600
2580
  "register it in the media library. Returns the media item with id, " +
2601
2581
  "storageKey, and url — ready to reference from content fields (e.g. " +
2602
2582
  "featured_image) via content_create / content_update. Uploads are " +
2603
2583
  "deduplicated by content hash: re-uploading identical bytes returns " +
2604
- "the existing item with deduplicated: true. URL fetches must resolve " +
2605
- "to a public http(s) host (SSRF-guarded). Subject to the global " +
2584
+ "the existing item with deduplicated: true. Subject to the global " +
2606
2585
  "upload MIME allowlist and the configured maximum upload size.",
2607
2586
  inputSchema: z.object({
2608
2587
  filename: z.string().min(1).describe("Filename including extension (e.g. 'cover.png')"),
2609
- base64: z
2610
- .string()
2611
- .optional()
2612
- .describe("Base64-encoded file contents. Provide exactly one of base64 / url."),
2613
- url: z
2614
- .url()
2615
- .optional()
2616
- .describe(
2617
- "Public http(s) URL to fetch the file from. Provide exactly one of base64 / url.",
2618
- ),
2588
+ base64: z.string().describe("Base64-encoded file contents."),
2619
2589
  contentType: z
2620
2590
  .string()
2621
2591
  .regex(CONTENT_TYPE_RE, "Invalid content type")
2622
- .optional()
2623
- .describe(
2624
- "MIME type (e.g. 'image/png'). Required with base64; with url it " +
2625
- "defaults to the response's Content-Type header.",
2626
- ),
2592
+ .describe("MIME type (e.g. 'image/png')."),
2627
2593
  alt: z.string().optional().describe("Alt text for accessibility"),
2628
2594
  }),
2629
2595
  annotations: { destructiveHint: false },
@@ -2640,7 +2606,6 @@ export function createMcpServer(
2640
2606
  await emdash.handleMediaUpload({
2641
2607
  filename: args.filename,
2642
2608
  base64: args.base64,
2643
- url: args.url,
2644
2609
  contentType: args.contentType,
2645
2610
  alt: args.alt,
2646
2611
  authorId: userId,