emdash 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/dist/{adapters-C2BzVy0p.d.mts → adapters-Di31kZ28.d.mts} +16 -1
  2. package/dist/adapters-Di31kZ28.d.mts.map +1 -0
  3. package/dist/{apply-Cma_PiF6.mjs → apply-5uslYdUu.mjs} +197 -25
  4. package/dist/apply-5uslYdUu.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +6 -6
  6. package/dist/astro/index.d.mts.map +1 -1
  7. package/dist/astro/index.mjs +203 -33
  8. package/dist/astro/index.mjs.map +1 -1
  9. package/dist/astro/middleware/auth.d.mts +5 -5
  10. package/dist/astro/middleware/auth.d.mts.map +1 -1
  11. package/dist/astro/middleware/auth.mjs +30 -4
  12. package/dist/astro/middleware/auth.mjs.map +1 -1
  13. package/dist/astro/middleware/redirect.mjs +2 -2
  14. package/dist/astro/middleware/request-context.d.mts.map +1 -1
  15. package/dist/astro/middleware/request-context.mjs +11 -4
  16. package/dist/astro/middleware/request-context.mjs.map +1 -1
  17. package/dist/astro/middleware/setup.mjs +1 -1
  18. package/dist/astro/middleware.d.mts.map +1 -1
  19. package/dist/astro/middleware.mjs +467 -186
  20. package/dist/astro/middleware.mjs.map +1 -1
  21. package/dist/astro/types.d.mts +17 -9
  22. package/dist/astro/types.d.mts.map +1 -1
  23. package/dist/{byline-WuOq9MFJ.mjs → byline-C4OVd8b3.mjs} +3 -19
  24. package/dist/byline-C4OVd8b3.mjs.map +1 -0
  25. package/dist/{bylines-C_Wsnz4L.mjs → bylines-hPTW79hw.mjs} +20 -33
  26. package/dist/bylines-hPTW79hw.mjs.map +1 -0
  27. package/dist/{cache-E3Dts-yT.mjs → cache-BkKBuIvS.mjs} +1 -1
  28. package/dist/{cache-E3Dts-yT.mjs.map → cache-BkKBuIvS.mjs.map} +1 -1
  29. package/dist/chunks-HGz06Soa.mjs +19 -0
  30. package/dist/chunks-HGz06Soa.mjs.map +1 -0
  31. package/dist/cli/index.mjs +12 -11
  32. package/dist/cli/index.mjs.map +1 -1
  33. package/dist/client/cf-access.d.mts +1 -1
  34. package/dist/client/index.d.mts +1 -1
  35. package/dist/client/index.mjs +1 -1
  36. package/dist/{config-DkxPrM9l.mjs → config-BXwuX8Bx.mjs} +1 -1
  37. package/dist/{config-DkxPrM9l.mjs.map → config-BXwuX8Bx.mjs.map} +1 -1
  38. package/dist/{connection-B4zVnQIa.mjs → connection-2igzM-AT.mjs} +19 -2
  39. package/dist/connection-2igzM-AT.mjs.map +1 -0
  40. package/dist/{content-BsBoyj8G.mjs → content-D7J5y73J.mjs} +27 -1
  41. package/dist/{content-BsBoyj8G.mjs.map → content-D7J5y73J.mjs.map} +1 -1
  42. package/dist/database/instrumentation.d.mts +45 -0
  43. package/dist/database/instrumentation.d.mts.map +1 -0
  44. package/dist/database/instrumentation.mjs +61 -0
  45. package/dist/database/instrumentation.mjs.map +1 -0
  46. package/dist/db/index.d.mts +3 -3
  47. package/dist/db/index.mjs +1 -1
  48. package/dist/db/index.mjs.map +1 -1
  49. package/dist/db/libsql.d.mts +1 -1
  50. package/dist/db/postgres.d.mts +1 -1
  51. package/dist/db/sqlite.d.mts +1 -1
  52. package/dist/db-errors-D0UT85nC.mjs +41 -0
  53. package/dist/db-errors-D0UT85nC.mjs.map +1 -0
  54. package/dist/{default-PUx9RK6u.mjs → default-CME5YdZ3.mjs} +1 -1
  55. package/dist/{default-PUx9RK6u.mjs.map → default-CME5YdZ3.mjs.map} +1 -1
  56. package/dist/{error-HBeQbVhV.mjs → error-CiYn9yDu.mjs} +1 -1
  57. package/dist/{error-HBeQbVhV.mjs.map → error-CiYn9yDu.mjs.map} +1 -1
  58. package/dist/{index-CCWzlriB.d.mts → index-De6_Xv3v.d.mts} +209 -19
  59. package/dist/index-De6_Xv3v.d.mts.map +1 -0
  60. package/dist/index.d.mts +11 -11
  61. package/dist/index.mjs +23 -21
  62. package/dist/{load-BhSSm-TS.mjs → load-CBcmDIot.mjs} +1 -1
  63. package/dist/{load-BhSSm-TS.mjs.map → load-CBcmDIot.mjs.map} +1 -1
  64. package/dist/{loader-BYzwzORf.mjs → loader-DeiBJEMe.mjs} +18 -12
  65. package/dist/loader-DeiBJEMe.mjs.map +1 -0
  66. package/dist/{manifest-schema-BsXINkQD.mjs → manifest-schema-V30qsMft.mjs} +1 -1
  67. package/dist/{manifest-schema-BsXINkQD.mjs.map → manifest-schema-V30qsMft.mjs.map} +1 -1
  68. package/dist/media/index.d.mts +1 -1
  69. package/dist/media/index.mjs +1 -1
  70. package/dist/media/local-runtime.d.mts +7 -7
  71. package/dist/{mode-CyPLdO3C.mjs → mode-CpNnGkPz.mjs} +1 -1
  72. package/dist/{mode-CyPLdO3C.mjs.map → mode-CpNnGkPz.mjs.map} +1 -1
  73. package/dist/page/index.d.mts +11 -2
  74. package/dist/page/index.d.mts.map +1 -1
  75. package/dist/page/index.mjs +23 -1
  76. package/dist/page/index.mjs.map +1 -1
  77. package/dist/{placeholder-DntBEQo7.mjs → placeholder-C-fk5hYI.mjs} +1 -1
  78. package/dist/{placeholder-DntBEQo7.mjs.map → placeholder-C-fk5hYI.mjs.map} +1 -1
  79. package/dist/{placeholder-BBCtpTES.d.mts → placeholder-tzpqGWII.d.mts} +1 -1
  80. package/dist/{placeholder-BBCtpTES.d.mts.map → placeholder-tzpqGWII.d.mts.map} +1 -1
  81. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  82. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  83. package/dist/{query-B6Vu0d2i.mjs → query-g4Ug-9j9.mjs} +79 -12
  84. package/dist/query-g4Ug-9j9.mjs.map +1 -0
  85. package/dist/{redirect-7lGhLBNZ.mjs → redirect-CN0Rt9Ob.mjs} +66 -10
  86. package/dist/redirect-CN0Rt9Ob.mjs.map +1 -0
  87. package/dist/{registry-BgnP3ysR.mjs → registry-Ci3WxVAr.mjs} +133 -97
  88. package/dist/registry-Ci3WxVAr.mjs.map +1 -0
  89. package/dist/request-cache-DiR961CV.mjs +79 -0
  90. package/dist/request-cache-DiR961CV.mjs.map +1 -0
  91. package/dist/request-context.d.mts +19 -16
  92. package/dist/request-context.d.mts.map +1 -1
  93. package/dist/request-context.mjs.map +1 -1
  94. package/dist/{runner-DYv3rX8P.d.mts → runner-BR2xKwhn.d.mts} +2 -2
  95. package/dist/{runner-DYv3rX8P.d.mts.map → runner-BR2xKwhn.d.mts.map} +1 -1
  96. package/dist/{runner-Cd-_WyDo.mjs → runner-tQ7BJ4T7.mjs} +211 -134
  97. package/dist/runner-tQ7BJ4T7.mjs.map +1 -0
  98. package/dist/runtime.d.mts +6 -6
  99. package/dist/runtime.mjs +1 -1
  100. package/dist/{search-Cn1SYvYF.mjs → search-B0effn3j.mjs} +210 -226
  101. package/dist/search-B0effn3j.mjs.map +1 -0
  102. package/dist/seed/index.d.mts +2 -2
  103. package/dist/seed/index.mjs +10 -9
  104. package/dist/seo/index.d.mts +1 -1
  105. package/dist/storage/local.d.mts +1 -1
  106. package/dist/storage/local.mjs +1 -1
  107. package/dist/storage/s3.d.mts +1 -1
  108. package/dist/storage/s3.mjs +1 -1
  109. package/dist/taxonomies-K2z0Uhnj.mjs +308 -0
  110. package/dist/taxonomies-K2z0Uhnj.mjs.map +1 -0
  111. package/dist/{tokens-DKHiCYCB.mjs → tokens-BFPFx3CA.mjs} +1 -1
  112. package/dist/{tokens-DKHiCYCB.mjs.map → tokens-BFPFx3CA.mjs.map} +1 -1
  113. package/dist/{transport-BtcQ-Z7T.mjs → transport-BykRfpyy.mjs} +1 -1
  114. package/dist/{transport-BtcQ-Z7T.mjs.map → transport-BykRfpyy.mjs.map} +1 -1
  115. package/dist/{transport-CKQA_G44.d.mts → transport-H4Iwx7tC.d.mts} +1 -1
  116. package/dist/{transport-CKQA_G44.d.mts.map → transport-H4Iwx7tC.d.mts.map} +1 -1
  117. package/dist/{types-BmkQR1En.d.mts → types-6CUZRrZP.d.mts} +1 -1
  118. package/dist/{types-BmkQR1En.d.mts.map → types-6CUZRrZP.d.mts.map} +1 -1
  119. package/dist/{types-Dz9_WMS6.mjs → types-BH2L167P.mjs} +1 -1
  120. package/dist/{types-Dz9_WMS6.mjs.map → types-BH2L167P.mjs.map} +1 -1
  121. package/dist/{types-B6BzlZxx.d.mts → types-C2v0c34j.d.mts} +10 -1
  122. package/dist/{types-B6BzlZxx.d.mts.map → types-C2v0c34j.d.mts.map} +1 -1
  123. package/dist/{types-DNZpaCBk.d.mts → types-CFWjXmus.d.mts} +1 -1
  124. package/dist/{types-DNZpaCBk.d.mts.map → types-CFWjXmus.d.mts.map} +1 -1
  125. package/dist/{types-DeG21anB.d.mts → types-CnZYHyLW.d.mts} +55 -5
  126. package/dist/types-CnZYHyLW.d.mts.map +1 -0
  127. package/dist/{types-xxCWI3j0.mjs → types-DDS4MxsT.mjs} +11 -3
  128. package/dist/types-DDS4MxsT.mjs.map +1 -0
  129. package/dist/{types-C3ronwXb.d.mts → types-DgrIP0tF.d.mts} +102 -4
  130. package/dist/types-DgrIP0tF.d.mts.map +1 -0
  131. package/dist/{validate-DuZDIxfy.mjs → validate-CqsNItbt.mjs} +2 -2
  132. package/dist/{validate-DuZDIxfy.mjs.map → validate-CqsNItbt.mjs.map} +1 -1
  133. package/dist/{validate-Db1yNL3i.d.mts → validate-kM8Pjuf7.d.mts} +5 -52
  134. package/dist/validate-kM8Pjuf7.d.mts.map +1 -0
  135. package/dist/version-BnTKdfam.mjs +7 -0
  136. package/dist/{version-CMMjTuqu.mjs.map → version-BnTKdfam.mjs.map} +1 -1
  137. package/package.json +10 -5
  138. package/src/after.ts +62 -0
  139. package/src/api/handlers/content.ts +2 -0
  140. package/src/api/handlers/oauth-authorization.ts +2 -32
  141. package/src/api/handlers/oauth-clients.ts +40 -4
  142. package/src/api/handlers/taxonomies.ts +13 -0
  143. package/src/api/oauth/redirect-uri.ts +34 -0
  144. package/src/api/openapi/document.ts +126 -118
  145. package/src/api/schemas/content.ts +8 -0
  146. package/src/api/schemas/media.ts +26 -15
  147. package/src/api/schemas/schema.ts +1 -0
  148. package/src/astro/integration/font-provider.ts +178 -0
  149. package/src/astro/integration/index.ts +44 -0
  150. package/src/astro/integration/routes.ts +6 -0
  151. package/src/astro/integration/runtime.ts +117 -0
  152. package/src/astro/integration/virtual-modules.ts +41 -39
  153. package/src/astro/integration/vite-config.ts +16 -5
  154. package/src/astro/middleware/auth.ts +33 -1
  155. package/src/astro/middleware/request-context.ts +15 -3
  156. package/src/astro/middleware.ts +340 -263
  157. package/src/astro/routes/admin.astro +21 -10
  158. package/src/astro/routes/api/auth/magic-link/send.ts +2 -1
  159. package/src/astro/routes/api/auth/passkey/options.ts +2 -1
  160. package/src/astro/routes/api/auth/passkey/verify.ts +5 -1
  161. package/src/astro/routes/api/auth/signup/request.ts +26 -8
  162. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +10 -6
  163. package/src/astro/routes/api/content/[collection]/[id]/compare.ts +1 -1
  164. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +1 -1
  165. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +1 -1
  166. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +5 -0
  167. package/src/astro/routes/api/content/[collection]/[id]/translations.ts +26 -0
  168. package/src/astro/routes/api/content/[collection]/[id].ts +30 -2
  169. package/src/astro/routes/api/content/[collection]/index.ts +19 -1
  170. package/src/astro/routes/api/content/[collection]/trash.ts +1 -1
  171. package/src/astro/routes/api/import/wordpress/execute.ts +1 -1
  172. package/src/astro/routes/api/import/wordpress-plugin/analyze.ts +4 -3
  173. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +5 -4
  174. package/src/astro/routes/api/manifest.ts +7 -0
  175. package/src/astro/routes/api/media/upload-url.ts +10 -2
  176. package/src/astro/routes/api/media.ts +10 -7
  177. package/src/astro/routes/api/oauth/device/code.ts +2 -1
  178. package/src/astro/routes/api/oauth/device/token.ts +2 -1
  179. package/src/astro/routes/api/oauth/register.ts +178 -0
  180. package/src/astro/routes/api/oauth/token.ts +15 -0
  181. package/src/astro/routes/api/openapi.json.ts +15 -5
  182. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +2 -0
  183. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +1 -0
  184. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +1 -0
  185. package/src/astro/routes/api/search/index.ts +5 -0
  186. package/src/astro/routes/api/search/suggest.ts +3 -0
  187. package/src/astro/routes/api/setup/admin-verify.ts +30 -5
  188. package/src/astro/routes/api/setup/admin.ts +32 -8
  189. package/src/astro/routes/api/setup/index.ts +5 -2
  190. package/src/astro/routes/api/taxonomies/index.ts +1 -0
  191. package/src/astro/routes/api/well-known/oauth-authorization-server.ts +1 -1
  192. package/src/astro/types.ts +9 -0
  193. package/src/auth/rate-limit.ts +50 -22
  194. package/src/auth/setup-nonce.ts +22 -0
  195. package/src/auth/trusted-proxy.ts +92 -0
  196. package/src/bylines/index.ts +22 -45
  197. package/src/components/EmDashHead.astro +23 -7
  198. package/src/database/connection.ts +23 -1
  199. package/src/database/instrumentation.ts +98 -0
  200. package/src/database/migrations/035_bounded_404_log.ts +112 -0
  201. package/src/database/migrations/runner.ts +2 -0
  202. package/src/database/repositories/content.ts +39 -0
  203. package/src/database/repositories/options.ts +25 -0
  204. package/src/database/repositories/redirect.ts +111 -8
  205. package/src/database/types.ts +9 -0
  206. package/src/db/adapters.ts +15 -0
  207. package/src/emdash-runtime.ts +312 -92
  208. package/src/import/registry.ts +4 -3
  209. package/src/import/ssrf.ts +253 -12
  210. package/src/index.ts +6 -0
  211. package/src/loader.ts +19 -24
  212. package/src/mcp/server.ts +76 -3
  213. package/src/menus/index.ts +6 -3
  214. package/src/page/index.ts +1 -1
  215. package/src/page/seo-contributions.ts +36 -0
  216. package/src/plugins/context.ts +15 -3
  217. package/src/plugins/manager.ts +6 -0
  218. package/src/plugins/request-meta.ts +66 -15
  219. package/src/plugins/routes.ts +3 -1
  220. package/src/query.ts +104 -7
  221. package/src/request-cache.ts +106 -0
  222. package/src/request-context.ts +19 -0
  223. package/src/schema/query.ts +5 -2
  224. package/src/schema/registry.ts +243 -166
  225. package/src/schema/types.ts +13 -2
  226. package/src/schema/zod-generator.ts +4 -0
  227. package/src/search/fts-manager.ts +19 -5
  228. package/src/search/query.ts +4 -3
  229. package/src/seed/apply.ts +41 -1
  230. package/src/settings/index.ts +24 -5
  231. package/src/taxonomies/index.ts +324 -124
  232. package/src/utils/db-errors.ts +46 -0
  233. package/src/virtual-modules.d.ts +31 -10
  234. package/src/visual-editing/toolbar.ts +6 -1
  235. package/src/widgets/index.ts +54 -25
  236. package/dist/adapters-C2BzVy0p.d.mts.map +0 -1
  237. package/dist/apply-Cma_PiF6.mjs.map +0 -1
  238. package/dist/byline-WuOq9MFJ.mjs.map +0 -1
  239. package/dist/bylines-C_Wsnz4L.mjs.map +0 -1
  240. package/dist/connection-B4zVnQIa.mjs.map +0 -1
  241. package/dist/index-CCWzlriB.d.mts.map +0 -1
  242. package/dist/loader-BYzwzORf.mjs.map +0 -1
  243. package/dist/query-B6Vu0d2i.mjs.map +0 -1
  244. package/dist/redirect-7lGhLBNZ.mjs.map +0 -1
  245. package/dist/registry-BgnP3ysR.mjs.map +0 -1
  246. package/dist/runner-Cd-_WyDo.mjs.map +0 -1
  247. package/dist/search-Cn1SYvYF.mjs.map +0 -1
  248. package/dist/types-C3ronwXb.d.mts.map +0 -1
  249. package/dist/types-DeG21anB.d.mts.map +0 -1
  250. package/dist/types-xxCWI3j0.mjs.map +0 -1
  251. package/dist/validate-Db1yNL3i.d.mts.map +0 -1
  252. package/dist/version-CMMjTuqu.mjs +0 -7
@@ -9,6 +9,7 @@ import "@emdash-cms/admin/styles.css";
9
9
  // Use package-qualified import so Astro generates a proper module URL
10
10
  // (relative imports resolve to absolute paths which break client hydration)
11
11
  import AdminWrapper from "emdash/routes/PluginRegistry";
12
+ import { Font } from "astro:assets";
12
13
 
13
14
  export const prerender = false;
14
15
 
@@ -17,6 +18,9 @@ import { resolveLocale, loadMessages, getLocaleDir } from "@emdash-cms/admin/loc
17
18
  const resolvedLocale = resolveLocale(Astro.request);
18
19
  const resolvedDir = getLocaleDir(resolvedLocale);
19
20
  const messages = await loadMessages(resolvedLocale);
21
+
22
+ const adminConfig = Astro.locals.emdash?.config?.admin;
23
+ const pageTitle = adminConfig?.siteName ? `${adminConfig.siteName} Admin` : "EmDash Admin";
20
24
  ---
21
25
 
22
26
  <!doctype html>
@@ -24,13 +28,18 @@ const messages = await loadMessages(resolvedLocale);
24
28
  <head>
25
29
  <meta charset="UTF-8" />
26
30
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
27
- <link
28
- rel="icon"
29
- href="data:image/svg+xml,<svg width='75' height='75' viewBox='0 0 75 75' fill='none' xmlns='http://www.w3.org/2000/svg'> <g clip-path='url(%23clip0_50_99)'> <rect x='3' y='3' width='69' height='69' rx='10.518' stroke='url(%23paint0_linear_50_99)' stroke-width='6'/> <rect x='18' y='34' width='39.3661' height='6.56101' fill='url(%23paint1_linear_50_99)'/> </g> <defs> <linearGradient id='paint0_linear_50_99' x1='-42.9996' y1='124' x2='92.4233' y2='-41.7456' gradientUnits='userSpaceOnUse'> <stop stop-color='%230F006B'/> <stop offset='0.0833333' stop-color='%23281A81'/> <stop offset='0.166667' stop-color='%235D0C83'/> <stop offset='0.25' stop-color='%23911475'/> <stop offset='0.333333' stop-color='%23CE2F55'/> <stop offset='0.416667' stop-color='%23FF6633'/> <stop offset='0.5' stop-color='%23F6821F'/> <stop offset='0.583333' stop-color='%23FBAD41'/> <stop offset='0.666667' stop-color='%23FFCD89'/> <stop offset='0.75' stop-color='%23FFE9CB'/> <stop offset='0.833333' stop-color='%23FFF7EC'/> <stop offset='0.916667' stop-color='%23FFF8EE'/> <stop offset='1' stop-color='white'/> </linearGradient> <linearGradient id='paint1_linear_50_99' x1='91.4992' y1='27.4982' x2='28.1217' y2='54.1775' gradientUnits='userSpaceOnUse'> <stop stop-color='white'/> <stop offset='0.129253' stop-color='%23FFF8EE'/> <stop offset='0.617058' stop-color='%23FBAD41'/> <stop offset='0.848019' stop-color='%23F6821F'/> <stop offset='1' stop-color='%23FF6633'/> </linearGradient> <clipPath id='clip0_50_99'> <rect width='75' height='75' fill='white'/> </clipPath> </defs> </svg>"
30
- />
31
- <title>EmDash Admin</title>
31
+ <Font cssVariable="--font-emdash" />
32
+ {adminConfig?.favicon ? (
33
+ <link rel="icon" href={adminConfig.favicon} />
34
+ ) : (
35
+ <link
36
+ rel="icon"
37
+ href="data:image/svg+xml,<svg width='75' height='75' viewBox='0 0 75 75' fill='none' xmlns='http://www.w3.org/2000/svg'> <g clip-path='url(%23clip0_50_99)'> <rect x='3' y='3' width='69' height='69' rx='10.518' stroke='url(%23paint0_linear_50_99)' stroke-width='6'/> <rect x='18' y='34' width='39.3661' height='6.56101' fill='url(%23paint1_linear_50_99)'/> </g> <defs> <linearGradient id='paint0_linear_50_99' x1='-42.9996' y1='124' x2='92.4233' y2='-41.7456' gradientUnits='userSpaceOnUse'> <stop stop-color='%230F006B'/> <stop offset='0.0833333' stop-color='%23281A81'/> <stop offset='0.166667' stop-color='%235D0C83'/> <stop offset='0.25' stop-color='%23911475'/> <stop offset='0.333333' stop-color='%23CE2F55'/> <stop offset='0.416667' stop-color='%23FF6633'/> <stop offset='0.5' stop-color='%23F6821F'/> <stop offset='0.583333' stop-color='%23FBAD41'/> <stop offset='0.666667' stop-color='%23FFCD89'/> <stop offset='0.75' stop-color='%23FFE9CB'/> <stop offset='0.833333' stop-color='%23FFF7EC'/> <stop offset='0.916667' stop-color='%23FFF8EE'/> <stop offset='1' stop-color='white'/> </linearGradient> <linearGradient id='paint1_linear_50_99' x1='91.4992' y1='27.4982' x2='28.1217' y2='54.1775' gradientUnits='userSpaceOnUse'> <stop stop-color='white'/> <stop offset='0.129253' stop-color='%23FFF8EE'/> <stop offset='0.617058' stop-color='%23FBAD41'/> <stop offset='0.848019' stop-color='%23F6821F'/> <stop offset='1' stop-color='%23FF6633'/> </linearGradient> <clipPath id='clip0_50_99'> <rect width='75' height='75' fill='white'/> </clipPath> </defs> </svg>"
38
+ />
39
+ )}
40
+ <title>{pageTitle}</title>
32
41
  </head>
33
- <body>
42
+ <body class="isolate">
34
43
  <div id="admin-root" class="min-h-screen">
35
44
  <div id="emdash-boot-loader">
36
45
  <style>
@@ -63,10 +72,12 @@ const messages = await loadMessages(resolvedLocale);
63
72
  }
64
73
  #emdash-boot-loader p {
65
74
  margin-top: 1rem;
66
- font-family:
75
+ font-family: var(
76
+ --font-emdash,
77
+ ui-sans-serif,
67
78
  system-ui,
68
- -apple-system,
69
- sans-serif;
79
+ sans-serif
80
+ );
70
81
  font-size: 0.875rem;
71
82
  color: light-dark(hsl(215.4 16.3% 46.9%), hsl(215 20.2% 65.1%));
72
83
  }
@@ -78,7 +89,7 @@ const messages = await loadMessages(resolvedLocale);
78
89
  </style>
79
90
  <div class="loader-inner">
80
91
  <div class="spinner"></div>
81
- <p>Loading EmDash...</p>
92
+ <p>{adminConfig?.siteName ? `Loading ${adminConfig.siteName}...` : "Loading EmDash..."}</p>
82
93
  </div>
83
94
  </div>
84
95
  <AdminWrapper client:only="react" locale={resolvedLocale} messages={messages} />
@@ -19,6 +19,7 @@ import { isParseError, parseBody } from "#api/parse.js";
19
19
  import { magicLinkSendBody } from "#api/schemas.js";
20
20
  import { getSiteBaseUrl } from "#api/site-url.js";
21
21
  import { checkRateLimit, getClientIp } from "#auth/rate-limit.js";
22
+ import { getTrustedProxyHeaders } from "#auth/trusted-proxy.js";
22
23
  import { OptionsRepository } from "#db/repositories/options.js";
23
24
 
24
25
  export const POST: APIRoute = async ({ request, locals }) => {
@@ -36,7 +37,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
36
37
  if (isParseError(body)) return body;
37
38
 
38
39
  // Rate limit: 3 requests per 300 seconds (5 minutes) per IP
39
- const ip = getClientIp(request);
40
+ const ip = getClientIp(request, getTrustedProxyHeaders(emdash.config));
40
41
  const rateLimit = await checkRateLimit(emdash.db, ip, "magic-link/send", 3, 300);
41
42
  if (!rateLimit.allowed) {
42
43
  // Return success-shaped response to avoid revealing rate limit
@@ -20,6 +20,7 @@ import { passkeyOptionsBody } from "#api/schemas.js";
20
20
  import { createChallengeStore, cleanupExpiredChallenges } from "#auth/challenge-store.js";
21
21
  import { getPasskeyConfig } from "#auth/passkey-config.js";
22
22
  import { checkRateLimit, getClientIp, rateLimitResponse } from "#auth/rate-limit.js";
23
+ import { getTrustedProxyHeaders } from "#auth/trusted-proxy.js";
23
24
  import { OptionsRepository } from "#db/repositories/options.js";
24
25
 
25
26
  export const POST: APIRoute = async ({ request, locals }) => {
@@ -38,7 +39,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
38
39
  if (isParseError(body)) return body;
39
40
 
40
41
  // Rate limit: 10 requests per 60 seconds per IP
41
- const ip = getClientIp(request);
42
+ const ip = getClientIp(request, getTrustedProxyHeaders(emdash.config));
42
43
  const rateLimit = await checkRateLimit(emdash.db, ip, "passkey/options", 10, 60);
43
44
  if (!rateLimit.allowed) {
44
45
  return rateLimitResponse(60);
@@ -9,7 +9,7 @@ import type { APIRoute } from "astro";
9
9
  export const prerender = false;
10
10
 
11
11
  import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
12
- import { authenticateWithPasskey } from "@emdash-cms/auth/passkey";
12
+ import { authenticateWithPasskey, PasskeyAuthenticationError } from "@emdash-cms/auth/passkey";
13
13
 
14
14
  import { apiError, apiSuccess, handleError } from "#api/error.js";
15
15
  import { isParseError, parseBody } from "#api/parse.js";
@@ -63,6 +63,10 @@ export const POST: APIRoute = async ({ request, locals, session }) => {
63
63
  },
64
64
  });
65
65
  } catch (error) {
66
+ if (error instanceof PasskeyAuthenticationError) {
67
+ return apiError("UNAUTHORIZED", "Authentication failed", 401);
68
+ }
69
+
66
70
  return handleError(error, "Authentication failed", "PASSKEY_VERIFY_ERROR");
67
71
  }
68
72
  };
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Request self-signup. Sends verification email if domain is allowed.
5
5
  * Always returns 200 to prevent email enumeration.
6
+ *
7
+ * Rate limited: 3 requests per 5 minutes per IP. Mirrors magic-link/send.
6
8
  */
7
9
 
8
10
  import type { APIRoute } from "astro";
@@ -16,8 +18,18 @@ import { apiError, apiSuccess } from "#api/error.js";
16
18
  import { isParseError, parseBody } from "#api/parse.js";
17
19
  import { signupRequestBody } from "#api/schemas.js";
18
20
  import { getSiteBaseUrl } from "#api/site-url.js";
21
+ import { checkRateLimit, getClientIp } from "#auth/rate-limit.js";
22
+ import { getTrustedProxyHeaders } from "#auth/trusted-proxy.js";
19
23
  import { OptionsRepository } from "#db/repositories/options.js";
20
24
 
25
+ // Generic response body used for both the real success path and the
26
+ // rate-limited / domain-disallowed paths. Keeping them identical prevents
27
+ // the caller from distinguishing between them.
28
+ const GENERIC_SUCCESS = {
29
+ success: true,
30
+ message: "If your email domain is allowed, you'll receive a verification email.",
31
+ };
32
+
21
33
  export const POST: APIRoute = async ({ request, locals }) => {
22
34
  const { emdash } = locals;
23
35
 
@@ -35,9 +47,21 @@ export const POST: APIRoute = async ({ request, locals }) => {
35
47
  }
36
48
 
37
49
  try {
50
+ // Parse the body first — this avoids burning a rate-limit slot on
51
+ // malformed input and keeps the timing of the rate-limited and
52
+ // real paths aligned.
38
53
  const body = await parseBody(request, signupRequestBody);
39
54
  if (isParseError(body)) return body;
40
55
 
56
+ // Rate limit: 3 requests per 300 seconds per IP. Matches magic-link/send.
57
+ const ip = getClientIp(request, getTrustedProxyHeaders(emdash.config));
58
+ const rateLimit = await checkRateLimit(emdash.db, ip, "signup/request", 3, 300);
59
+ if (!rateLimit.allowed) {
60
+ // Return success-shaped response to avoid revealing rate limiting
61
+ // (and by extension, the fact that the caller is probing).
62
+ return apiSuccess(GENERIC_SUCCESS);
63
+ }
64
+
41
65
  const adapter = createKyselyAdapter(emdash.db);
42
66
 
43
67
  // Get site config for signup email
@@ -60,18 +84,12 @@ export const POST: APIRoute = async ({ request, locals }) => {
60
84
  );
61
85
 
62
86
  // Always return success to prevent email enumeration
63
- return apiSuccess({
64
- success: true,
65
- message: "If your email domain is allowed, you'll receive a verification email.",
66
- });
87
+ return apiSuccess(GENERIC_SUCCESS);
67
88
  } catch (error) {
68
89
  console.error("Signup request error:", error);
69
90
 
70
91
  // Don't reveal internal errors - just return generic success
71
92
  // to prevent information leakage
72
- return apiSuccess({
73
- success: true,
74
- message: "If your email domain is allowed, you'll receive a verification email.",
75
- });
93
+ return apiSuccess(GENERIC_SUCCESS);
76
94
  }
77
95
  };
@@ -139,18 +139,22 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
139
139
  }
140
140
 
141
141
  // Anti-spam: Rate limiting
142
- const meta = extractRequestMeta(request);
142
+ const meta = extractRequestMeta(request, emdash.config);
143
143
  const ipSalt =
144
144
  import.meta.env.EMDASH_AUTH_SECRET || import.meta.env.AUTH_SECRET || "emdash-ip-salt";
145
145
  let ipHash: string;
146
146
  if (meta.ip) {
147
147
  ipHash = await hashIp(meta.ip, ipSalt);
148
- } else if (meta.userAgent) {
149
- // Fallback: hash user-agent as a rough identifier when IP is unavailable
150
- ipHash = await hashIp(`ua:${meta.userAgent}`, ipSalt);
151
148
  } else {
152
- // Fail closed: all unidentifiable requests share one rate-limit bucket.
153
- // Use a larger limit since this bucket is shared across all anonymous users.
149
+ // No trusted IP fail closed by bucketing all unidentifiable
150
+ // requests together. A larger limit reflects the shared bucket.
151
+ //
152
+ // Self-hosted operators behind a reverse proxy should set
153
+ // `trustedProxyHeaders` in the EmDash config (or the
154
+ // EMDASH_TRUSTED_PROXY_HEADERS env var) so this path isn't hit
155
+ // for legitimate traffic. UA-hashing was previously used here
156
+ // but was trivially rotatable — the shared bucket is stricter
157
+ // and forces operators toward a real fix.
154
158
  ipHash = "unknown";
155
159
  }
156
160
  const unknownBucketLimit = ipHash === "unknown" ? 20 : undefined;
@@ -13,7 +13,7 @@ export const prerender = false;
13
13
 
14
14
  export const GET: APIRoute = async ({ params, locals }) => {
15
15
  const { emdash, user } = locals;
16
- const denied = requirePerm(user, "content:read");
16
+ const denied = requirePerm(user, "content:read_drafts");
17
17
  if (denied) return denied;
18
18
  const collection = params.collection!;
19
19
  const id = params.id!;
@@ -30,7 +30,7 @@ const DURATION_PATTERN = /^(\d+)([smhdw])$/;
30
30
 
31
31
  export const POST: APIRoute = async ({ params, request, locals }) => {
32
32
  const { emdash, user } = locals;
33
- const denied = requirePerm(user, "content:read");
33
+ const denied = requirePerm(user, "content:read_drafts");
34
34
  if (denied) return denied;
35
35
  const collection = params.collection!;
36
36
  const id = params.id!;
@@ -13,7 +13,7 @@ export const prerender = false;
13
13
 
14
14
  export const GET: APIRoute = async ({ params, url, locals }) => {
15
15
  const { emdash, user } = locals;
16
- const denied = requirePerm(user, "content:read");
16
+ const denied = requirePerm(user, "content:read_drafts");
17
17
  if (denied) return denied;
18
18
  const collection = params.collection!;
19
19
  const id = params.id!;
@@ -12,6 +12,7 @@ import { apiError, apiSuccess, handleError, requireDb } from "#api/error.js";
12
12
  import { parseBody, isParseError } from "#api/parse.js";
13
13
  import { contentTermsBody } from "#api/schemas.js";
14
14
  import { TaxonomyRepository } from "#db/repositories/taxonomy.js";
15
+ import { invalidateTermCache } from "#taxonomies/index.js";
15
16
 
16
17
  export const prerender = false;
17
18
 
@@ -122,6 +123,10 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
122
123
  // Set the terms (replaces existing)
123
124
  await repo.setTermsForEntry(collection, id, taxonomy, termIds);
124
125
 
126
+ // Term assignments changed — invalidate the hasAnyTermAssignments cache
127
+ // so hydration on subsequent reads issues a fresh query.
128
+ invalidateTermCache();
129
+
125
130
  // Get the updated terms
126
131
  const terms = await repo.getTermsForEntry(collection, id, taxonomy);
127
132
 
@@ -6,6 +6,7 @@
6
6
  * Returns all locale variants linked to the same translation group.
7
7
  */
8
8
 
9
+ import { hasPermission, type Permission } from "@emdash-cms/auth";
9
10
  import type { APIRoute } from "astro";
10
11
 
11
12
  import { requirePerm } from "#api/authorize.js";
@@ -13,6 +14,15 @@ import { apiError, unwrapResult } from "#api/error.js";
13
14
 
14
15
  export const prerender = false;
15
16
 
17
+ function isPublished(t: unknown): boolean {
18
+ return (
19
+ typeof t === "object" &&
20
+ t !== null &&
21
+ "status" in t &&
22
+ (t as Record<string, unknown>).status === "published"
23
+ );
24
+ }
25
+
16
26
  export const GET: APIRoute = async ({ params, locals }) => {
17
27
  const { emdash, user } = locals;
18
28
  const denied = requirePerm(user, "content:read");
@@ -26,5 +36,21 @@ export const GET: APIRoute = async ({ params, locals }) => {
26
36
 
27
37
  const result = await emdash.handleContentTranslations(collection, id);
28
38
 
39
+ // Filter out non-published translations for users without read_drafts so a
40
+ // subscriber can't enumerate locales that aren't yet live.
41
+ if (result.success && !hasPermission(user, "content:read_drafts")) {
42
+ const data =
43
+ result.data && typeof result.data === "object"
44
+ ? // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- handler returns unknown data; narrowed by typeof check
45
+ (result.data as Record<string, unknown>)
46
+ : undefined;
47
+ const translations = Array.isArray(data?.translations) ? data.translations : [];
48
+ const filtered = translations.filter(isPublished);
49
+ return unwrapResult({
50
+ success: true,
51
+ data: { ...data, translations: filtered },
52
+ });
53
+ }
54
+
29
55
  return unwrapResult(result);
30
56
  };
@@ -6,7 +6,7 @@
6
6
  * DELETE /_emdash/api/content/{collection}/{id} - Delete content
7
7
  */
8
8
 
9
- import { hasPermission, type Permission } from "@emdash-cms/auth";
9
+ import { hasPermission } from "@emdash-cms/auth";
10
10
  import type { APIRoute } from "astro";
11
11
 
12
12
  import { requirePerm, requireOwnerPerm } from "#api/authorize.js";
@@ -30,6 +30,25 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
30
30
 
31
31
  const result = await emdash.handleContentGet(collection, id, locale);
32
32
 
33
+ // Hide non-published items from users without content:read_drafts. Return
34
+ // 404 (not 403) so subscribers can't enumerate draft IDs by status code.
35
+ if (result.success && !hasPermission(user, "content:read_drafts")) {
36
+ const data =
37
+ result.data && typeof result.data === "object"
38
+ ? // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- handler returns unknown data; narrowed by typeof check
39
+ (result.data as Record<string, unknown>)
40
+ : undefined;
41
+ const item =
42
+ data?.item && typeof data.item === "object"
43
+ ? // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- narrowed by typeof check
44
+ (data.item as Record<string, unknown>)
45
+ : undefined;
46
+ const status = typeof item?.status === "string" ? item.status : null;
47
+ if (status !== "published") {
48
+ return apiError("NOT_FOUND", `Content item not found: ${id}`, 404);
49
+ }
50
+ }
51
+
33
52
  return unwrapResult(result);
34
53
  };
35
54
 
@@ -69,12 +88,21 @@ export const PUT: APIRoute = async ({ params, request, locals, cache }) => {
69
88
  const editDenied = requireOwnerPerm(user, authorId, "content:edit_own", "content:edit_any");
70
89
  if (editDenied) return editDenied;
71
90
 
91
+ // Only EDITOR+ can write publishedAt directly — incl. clearing to null.
92
+ if (body.publishedAt !== undefined && !hasPermission(user, "content:publish_any")) {
93
+ return apiError(
94
+ "FORBIDDEN",
95
+ "Writing publishedAt requires content:publish_any permission",
96
+ 403,
97
+ );
98
+ }
99
+
72
100
  // Use the resolved ID (handles slug → ID resolution)
73
101
  const resolvedId = typeof existingItem?.id === "string" ? existingItem.id : id;
74
102
 
75
103
  // Only allow authorId changes if user has content:edit_any permission (editor+)
76
104
  const canChangeAuthor =
77
- body.authorId !== undefined && user && hasPermission(user, "content:edit_any" as Permission);
105
+ body.authorId !== undefined && user && hasPermission(user, "content:edit_any");
78
106
  const updateBody = canChangeAuthor ? body : { ...body, authorId: undefined };
79
107
 
80
108
  // Pass _rev through for optimistic concurrency validation
@@ -5,6 +5,7 @@
5
5
  * POST /_emdash/api/content/{collection} - Create content
6
6
  */
7
7
 
8
+ import { hasPermission } from "@emdash-cms/auth";
8
9
  import type { APIRoute } from "astro";
9
10
 
10
11
  import { requirePerm, requireOwnerPerm } from "#api/authorize.js";
@@ -26,7 +27,14 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
26
27
  return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
27
28
  }
28
29
 
29
- const result = await emdash.handleContentList(collection, query);
30
+ // Subscribers must only see published content; force the status filter
31
+ // regardless of caller-supplied value. Any user with content:read_drafts
32
+ // (CONTRIBUTOR+) keeps the requested filter.
33
+ const params_ = hasPermission(user, "content:read_drafts")
34
+ ? query
35
+ : { ...query, status: "published" };
36
+
37
+ const result = await emdash.handleContentList(collection, params_);
30
38
 
31
39
  return unwrapResult(result);
32
40
  };
@@ -71,6 +79,16 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
71
79
  if (translationDenied) return translationDenied;
72
80
  }
73
81
 
82
+ // Only EDITOR+ can write publishedAt / createdAt directly — incl. clearing to null.
83
+ const hasDateOverride = body.publishedAt !== undefined || body.createdAt !== undefined;
84
+ if (hasDateOverride && !hasPermission(user, "content:publish_any")) {
85
+ return apiError(
86
+ "FORBIDDEN",
87
+ "Writing publishedAt or createdAt requires content:publish_any permission",
88
+ 403,
89
+ );
90
+ }
91
+
74
92
  // Auto-set authorId to current user when creating content
75
93
  const result = await emdash.handleContentCreate(collection, {
76
94
  ...body,
@@ -17,7 +17,7 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
17
17
  const { emdash, user } = locals;
18
18
  const collection = params.collection!;
19
19
 
20
- const denied = requirePerm(user, "content:read");
20
+ const denied = requirePerm(user, "content:read_drafts");
21
21
  if (denied) return denied;
22
22
 
23
23
  if (!emdash?.handleContentListTrashed) {
@@ -271,7 +271,7 @@ async function importContent(
271
271
  console.error(`Import error for "${post.title || "Untitled"}":`, error);
272
272
  result.errors.push({
273
273
  title: post.title || "Untitled",
274
- error: "Failed to import item",
274
+ error: error instanceof Error && error.message ? error.message : "Failed to import item",
275
275
  });
276
276
  }
277
277
  }
@@ -15,7 +15,7 @@ import { apiError, apiSuccess, handleError } from "#api/error.js";
15
15
  import { isParseError, parseBody } from "#api/parse.js";
16
16
  import { wpPluginAnalyzeBody } from "#api/schemas.js";
17
17
  import { getSource } from "#import/index.js";
18
- import { validateExternalUrl, SsrfError } from "#import/ssrf.js";
18
+ import { resolveAndValidateExternalUrl, SsrfError } from "#import/ssrf.js";
19
19
  import type { ImportAnalysis } from "#import/types.js";
20
20
  import type { EmDashHandlers } from "#types";
21
21
 
@@ -37,9 +37,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
37
37
  const body = await parseBody(request, wpPluginAnalyzeBody);
38
38
  if (isParseError(body)) return body;
39
39
 
40
- // SSRF: reject internal/private network targets
40
+ // SSRF: reject internal/private network targets. Uses DNS resolution
41
+ // to catch hostnames that resolve to private addresses.
41
42
  try {
42
- validateExternalUrl(body.url);
43
+ await resolveAndValidateExternalUrl(body.url);
43
44
  } catch (e) {
44
45
  const msg = e instanceof SsrfError ? e.message : "Invalid URL";
45
46
  return apiError("SSRF_BLOCKED", msg, 400);
@@ -15,7 +15,7 @@ import { isParseError, parseBody } from "#api/parse.js";
15
15
  import { wpPluginExecuteBody } from "#api/schemas.js";
16
16
  import { BylineRepository } from "#db/repositories/byline.js";
17
17
  import { getSource } from "#import/index.js";
18
- import { validateExternalUrl, SsrfError } from "#import/ssrf.js";
18
+ import { resolveAndValidateExternalUrl, SsrfError } from "#import/ssrf.js";
19
19
  import type { ImportConfig, ImportResult, NormalizedItem } from "#import/types.js";
20
20
  import { resolveImportByline } from "#import/utils.js";
21
21
  import type { FieldType } from "#schema/types.js";
@@ -49,9 +49,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
49
49
  const body = await parseBody(request, wpPluginExecuteBody);
50
50
  if (isParseError(body)) return body;
51
51
 
52
- // SSRF: reject internal/private network targets
52
+ // SSRF: reject internal/private network targets. Uses DNS resolution
53
+ // to catch hostnames that resolve to private addresses.
53
54
  try {
54
- validateExternalUrl(body.url);
55
+ await resolveAndValidateExternalUrl(body.url);
55
56
  } catch (e) {
56
57
  const msg = e instanceof SsrfError ? e.message : "Invalid URL";
57
58
  return apiError("SSRF_BLOCKED", msg, 400);
@@ -332,7 +333,7 @@ async function importContent(
332
333
  console.error(`Import error for "${item.title || "Untitled"}":`, error);
333
334
  result.errors.push({
334
335
  title: item.title || "Untitled",
335
- error: "Failed to import item",
336
+ error: error instanceof Error && error.message ? error.message : "Failed to import item",
336
337
  });
337
338
  }
338
339
  }
@@ -12,6 +12,7 @@ import type { APIRoute } from "astro";
12
12
  import { getAuthMode } from "#auth/mode.js";
13
13
 
14
14
  import { COMMIT, VERSION } from "../../../version.js";
15
+ import { getStoredConfig } from "../../integration/runtime.js";
15
16
  import type { EmDashManifest } from "../../types.js";
16
17
 
17
18
  export const prerender = false;
@@ -22,6 +23,10 @@ export const GET: APIRoute = async ({ locals }) => {
22
23
  // Determine auth mode from config
23
24
  const authMode = getAuthMode(emdash?.config);
24
25
 
26
+ // Read admin branding from build-time config
27
+ const storedConfig = getStoredConfig();
28
+ const adminBranding = storedConfig?.admin;
29
+
25
30
  // Check if self-signup is enabled (any allowed domain with enabled = 1)
26
31
  // Only relevant for passkey auth — external auth providers handle their own signup
27
32
  let signupEnabled = false;
@@ -42,6 +47,7 @@ export const GET: APIRoute = async ({ locals }) => {
42
47
  ...emdashManifest,
43
48
  authMode: authMode.type === "external" ? authMode.providerType : "passkey",
44
49
  signupEnabled,
50
+ admin: adminBranding,
45
51
  }
46
52
  : {
47
53
  version: VERSION,
@@ -52,6 +58,7 @@ export const GET: APIRoute = async ({ locals }) => {
52
58
  taxonomies: [],
53
59
  authMode: "passkey",
54
60
  signupEnabled,
61
+ admin: adminBranding,
55
62
  };
56
63
 
57
64
  return Response.json(
@@ -16,7 +16,7 @@ import { ulid } from "ulidx";
16
16
  import { requirePerm } from "#api/authorize.js";
17
17
  import { apiError, apiSuccess, handleError } from "#api/error.js";
18
18
  import { isParseError, parseBody } from "#api/parse.js";
19
- import { mediaUploadUrlBody } from "#api/schemas.js";
19
+ import { DEFAULT_MAX_UPLOAD_SIZE, mediaUploadUrlBody } from "#api/schemas.js";
20
20
 
21
21
  export const prerender = false;
22
22
 
@@ -59,7 +59,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
59
59
  }
60
60
 
61
61
  try {
62
- const body = await parseBody(request, mediaUploadUrlBody);
62
+ const maxSize = emdash.config.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE;
63
+ if (!Number.isFinite(maxSize) || maxSize <= 0) {
64
+ return apiError(
65
+ "CONFIGURATION_ERROR",
66
+ "Invalid maxUploadSize configuration. Expected a positive finite number.",
67
+ 500,
68
+ );
69
+ }
70
+ const body = await parseBody(request, mediaUploadUrlBody(maxSize));
63
71
  if (isParseError(body)) return body;
64
72
 
65
73
  // Validate content type
@@ -13,7 +13,7 @@ import { ulid } from "ulidx";
13
13
  import { requirePerm } from "#api/authorize.js";
14
14
  import { apiError, apiSuccess, handleError, unwrapResult } from "#api/error.js";
15
15
  import { isParseError, parseQuery } from "#api/parse.js";
16
- import { mediaListQuery } from "#api/schemas.js";
16
+ import { DEFAULT_MAX_UPLOAD_SIZE, formatFileSize, mediaListQuery } from "#api/schemas.js";
17
17
  import { MediaRepository } from "#db/repositories/media.js";
18
18
  import { generatePlaceholder } from "#media/placeholder.js";
19
19
  import { computeContentHash } from "#utils/hash.js";
@@ -22,9 +22,6 @@ import type { MediaItem } from "../../types.js";
22
22
 
23
23
  export const prerender = false;
24
24
 
25
- /** Maximum allowed file upload size (50 MB). */
26
- const MAX_UPLOAD_SIZE = 50 * 1024 * 1024;
27
-
28
25
  /**
29
26
  * Add URL to media items
30
27
  * Uses relative URLs to ensure portability across deployments
@@ -89,9 +86,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
89
86
  }
90
87
 
91
88
  try {
89
+ const rawMax = emdash.config.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE;
90
+ if (!Number.isFinite(rawMax) || rawMax <= 0) {
91
+ return apiError("CONFIGURATION_ERROR", "Invalid maxUploadSize configuration", 500);
92
+ }
93
+ const maxUploadSize = rawMax;
94
+
92
95
  // Best-effort size check before buffering the full multipart body
93
96
  const contentLength = request.headers.get("Content-Length");
94
- if (contentLength && parseInt(contentLength, 10) > MAX_UPLOAD_SIZE) {
97
+ if (contentLength && parseInt(contentLength, 10) > maxUploadSize) {
95
98
  return apiError("PAYLOAD_TOO_LARGE", "Upload too large", 413);
96
99
  }
97
100
 
@@ -110,10 +113,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
110
113
  }
111
114
 
112
115
  // Check file size before buffering
113
- if (file.size > MAX_UPLOAD_SIZE) {
116
+ if (file.size > maxUploadSize) {
114
117
  return apiError(
115
118
  "PAYLOAD_TOO_LARGE",
116
- `File exceeds maximum size of ${MAX_UPLOAD_SIZE / 1024 / 1024}MB`,
119
+ `File exceeds maximum size of ${formatFileSize(maxUploadSize)}`,
117
120
  413,
118
121
  );
119
122
  }
@@ -15,6 +15,7 @@ import { handleDeviceCodeRequest } from "#api/handlers/device-flow.js";
15
15
  import { isParseError, parseBody } from "#api/parse.js";
16
16
  import { getPublicOrigin } from "#api/public-url.js";
17
17
  import { checkRateLimit, getClientIp, rateLimitResponse } from "#auth/rate-limit.js";
18
+ import { getTrustedProxyHeaders } from "#auth/trusted-proxy.js";
18
19
 
19
20
  export const prerender = false;
20
21
 
@@ -35,7 +36,7 @@ export const POST: APIRoute = async ({ request, locals, url }) => {
35
36
  if (isParseError(body)) return body;
36
37
 
37
38
  // Rate limit: 10 requests per 60 seconds per IP
38
- const ip = getClientIp(request);
39
+ const ip = getClientIp(request, getTrustedProxyHeaders(emdash.config));
39
40
  const rateLimit = await checkRateLimit(emdash.db, ip, "device/code", 10, 60);
40
41
  if (!rateLimit.allowed) {
41
42
  return rateLimitResponse(60);
@@ -17,6 +17,7 @@ import { apiError, handleError, unwrapResult } from "#api/error.js";
17
17
  import { handleDeviceTokenExchange } from "#api/handlers/device-flow.js";
18
18
  import { isParseError, parseBody } from "#api/parse.js";
19
19
  import { checkRateLimit, getClientIp, rateLimitResponse } from "#auth/rate-limit.js";
20
+ import { getTrustedProxyHeaders } from "#auth/trusted-proxy.js";
20
21
 
21
22
  export const prerender = false;
22
23
 
@@ -37,7 +38,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
37
38
  if (isParseError(body)) return body;
38
39
 
39
40
  // Rate limit: 12 requests per 60 seconds per IP
40
- const ip = getClientIp(request);
41
+ const ip = getClientIp(request, getTrustedProxyHeaders(emdash.config));
41
42
  const rateLimit = await checkRateLimit(emdash.db, ip, "device/token", 12, 60);
42
43
  if (!rateLimit.allowed) {
43
44
  return rateLimitResponse(60);