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
|
@@ -71,16 +71,22 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => {
|
|
|
71
71
|
const { emdash } = locals;
|
|
72
72
|
const provider = params.provider;
|
|
73
73
|
|
|
74
|
+
// Determine where to redirect errors (setup wizard or login page)
|
|
75
|
+
const referer = request.headers.get("referer") ?? "";
|
|
76
|
+
const errorRedirectBase = referer.includes("/setup")
|
|
77
|
+
? "/_emdash/admin/setup"
|
|
78
|
+
: "/_emdash/admin/login";
|
|
79
|
+
|
|
74
80
|
// Validate provider
|
|
75
81
|
if (!provider || !isValidProvider(provider)) {
|
|
76
82
|
return redirect(
|
|
77
|
-
|
|
83
|
+
`${errorRedirectBase}?error=invalid_provider&message=${encodeURIComponent("Invalid OAuth provider")}`,
|
|
78
84
|
);
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
if (!emdash?.db) {
|
|
82
88
|
return redirect(
|
|
83
|
-
|
|
89
|
+
`${errorRedirectBase}?error=server_error&message=${encodeURIComponent("Database not configured")}`,
|
|
84
90
|
);
|
|
85
91
|
}
|
|
86
92
|
|
|
@@ -97,7 +103,7 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => {
|
|
|
97
103
|
|
|
98
104
|
if (!providers[provider]) {
|
|
99
105
|
return redirect(
|
|
100
|
-
|
|
106
|
+
`${errorRedirectBase}?error=provider_not_configured&message=${encodeURIComponent(`OAuth provider ${provider} is not configured. Set either EMDASH_OAUTH_${provider.toUpperCase()}_CLIENT_ID and EMDASH_OAUTH_${provider.toUpperCase()}_CLIENT_SECRET, or ${provider.toUpperCase()}_CLIENT_ID and ${provider.toUpperCase()}_CLIENT_SECRET.`)}`,
|
|
101
107
|
);
|
|
102
108
|
}
|
|
103
109
|
|
|
@@ -114,7 +120,7 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => {
|
|
|
114
120
|
} catch (error) {
|
|
115
121
|
console.error("OAuth initiation error:", error);
|
|
116
122
|
return redirect(
|
|
117
|
-
|
|
123
|
+
`${errorRedirectBase}?error=oauth_error&message=${encodeURIComponent("Failed to start OAuth flow. Please try again.")}`,
|
|
118
124
|
);
|
|
119
125
|
}
|
|
120
126
|
};
|
|
@@ -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);
|
|
@@ -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
|
-
//
|
|
153
|
-
//
|
|
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:
|
|
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:
|
|
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:
|
|
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!;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Returns all locale variants linked to the same translation group.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { hasPermission } 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
|
|
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"
|
|
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
|
-
|
|
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
|
};
|
|
@@ -53,15 +61,7 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
|
|
|
53
61
|
mapErrorStatus(source.error?.code),
|
|
54
62
|
);
|
|
55
63
|
}
|
|
56
|
-
const
|
|
57
|
-
source.data && typeof source.data === "object"
|
|
58
|
-
? (source.data as Record<string, unknown>)
|
|
59
|
-
: undefined;
|
|
60
|
-
const sourceItem =
|
|
61
|
-
sourceData?.item && typeof sourceData.item === "object"
|
|
62
|
-
? (sourceData.item as Record<string, unknown>)
|
|
63
|
-
: sourceData;
|
|
64
|
-
const sourceAuthor = typeof sourceItem?.authorId === "string" ? sourceItem.authorId : "";
|
|
64
|
+
const sourceAuthor = source.data.item.authorId ?? "";
|
|
65
65
|
const translationDenied = requireOwnerPerm(
|
|
66
66
|
user,
|
|
67
67
|
sourceAuthor,
|
|
@@ -71,6 +71,16 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
|
|
|
71
71
|
if (translationDenied) return translationDenied;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// Only EDITOR+ can write publishedAt / createdAt directly — incl. clearing to null.
|
|
75
|
+
const hasDateOverride = body.publishedAt !== undefined || body.createdAt !== undefined;
|
|
76
|
+
if (hasDateOverride && !hasPermission(user, "content:publish_any")) {
|
|
77
|
+
return apiError(
|
|
78
|
+
"FORBIDDEN",
|
|
79
|
+
"Writing publishedAt or createdAt requires content:publish_any permission",
|
|
80
|
+
403,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
74
84
|
// Auto-set authorId to current user when creating content
|
|
75
85
|
const result = await emdash.handleContentCreate(collection, {
|
|
76
86
|
...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:
|
|
20
|
+
const denied = requirePerm(user, "content:read_drafts");
|
|
21
21
|
if (denied) return denied;
|
|
22
22
|
|
|
23
23
|
if (!emdash?.handleContentListTrashed) {
|
|
@@ -92,7 +92,6 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
92
92
|
attachments,
|
|
93
93
|
emdash.db,
|
|
94
94
|
emdash.storage,
|
|
95
|
-
request.url,
|
|
96
95
|
sendProgress,
|
|
97
96
|
);
|
|
98
97
|
|
|
@@ -117,7 +116,6 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
117
116
|
attachments,
|
|
118
117
|
emdash.db,
|
|
119
118
|
emdash.storage,
|
|
120
|
-
request.url,
|
|
121
119
|
() => {}, // No-op progress callback
|
|
122
120
|
);
|
|
123
121
|
|
|
@@ -131,12 +129,9 @@ async function importMediaWithProgress(
|
|
|
131
129
|
attachments: AttachmentInfo[],
|
|
132
130
|
db: NonNullable<EmDashHandlers["db"]>,
|
|
133
131
|
storage: NonNullable<EmDashHandlers["storage"]>,
|
|
134
|
-
requestUrl: string,
|
|
135
132
|
onProgress: (progress: MediaImportProgress) => void,
|
|
136
133
|
): Promise<MediaImportResult> {
|
|
137
134
|
const repo = new MediaRepository(db);
|
|
138
|
-
const url = new URL(requestUrl);
|
|
139
|
-
const baseUrl = `${url.protocol}//${url.host}`;
|
|
140
135
|
const total = attachments.length;
|
|
141
136
|
|
|
142
137
|
const result: MediaImportResult = {
|
|
@@ -237,7 +232,7 @@ async function importMediaWithProgress(
|
|
|
237
232
|
const existing = await repo.findByContentHash(contentHash);
|
|
238
233
|
if (existing) {
|
|
239
234
|
// Same content already exists - reuse it
|
|
240
|
-
const existingUrl =
|
|
235
|
+
const existingUrl = `/_emdash/api/media/file/${existing.storageKey}`;
|
|
241
236
|
result.urlMap[attachment.url] = existingUrl;
|
|
242
237
|
result.imported.push({
|
|
243
238
|
wpId: attachment.id,
|
|
@@ -290,7 +285,7 @@ async function importMediaWithProgress(
|
|
|
290
285
|
});
|
|
291
286
|
|
|
292
287
|
// Build the new URL
|
|
293
|
-
const newUrl =
|
|
288
|
+
const newUrl = `/_emdash/api/media/file/${storageKey}`;
|
|
294
289
|
|
|
295
290
|
result.imported.push({
|
|
296
291
|
wpId: attachment.id,
|
|
@@ -58,6 +58,16 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
58
58
|
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Zod schema output narrowed to PrepareRequest
|
|
59
59
|
const result = await prepareImport(emdash.db, body as PrepareRequest);
|
|
60
60
|
|
|
61
|
+
// If prepare created any new collections or fields, invalidate the
|
|
62
|
+
// persisted manifest cache (`emdash:manifest_cache` in the options
|
|
63
|
+
// table) so that the execute endpoint -- a separate request -- sees
|
|
64
|
+
// the new schema. Without this the execute step reads a stale
|
|
65
|
+
// manifest and reports `Collection "<slug>" does not exist` for
|
|
66
|
+
// every item destined for a freshly-created collection. See #747.
|
|
67
|
+
if (result.collectionsCreated.length > 0 || result.fieldsCreated.length > 0) {
|
|
68
|
+
emdash.invalidateManifest();
|
|
69
|
+
}
|
|
70
|
+
|
|
61
71
|
return apiSuccess(result, result.success ? 200 : 400);
|
|
62
72
|
} catch (error) {
|
|
63
73
|
return handleError(error, "Failed to prepare import", "WXR_PREPARE_ERROR");
|
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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);
|
|
@@ -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(
|
|
@@ -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);
|
|
@@ -49,20 +49,15 @@ export const GET: APIRoute = async ({ locals }) => {
|
|
|
49
49
|
`emdash:exclusive_hook:${EMAIL_DELIVER_HOOK}`,
|
|
50
50
|
);
|
|
51
51
|
|
|
52
|
-
// Get middleware hooks (beforeSend / afterSend)
|
|
52
|
+
// Get middleware hooks (beforeSend / afterSend). These are non-exclusive —
|
|
53
|
+
// many plugins can subscribe — so we enumerate non-exclusive providers.
|
|
53
54
|
const beforeSendPlugins = pipeline
|
|
54
|
-
.
|
|
55
|
+
.getHookProviders(EMAIL_BEFORE_SEND_HOOK)
|
|
55
56
|
.map((p) => p.pluginId);
|
|
56
57
|
const afterSendPlugins = pipeline
|
|
57
|
-
.
|
|
58
|
+
.getHookProviders(EMAIL_AFTER_SEND_HOOK)
|
|
58
59
|
.map((p) => p.pluginId);
|
|
59
60
|
|
|
60
|
-
// Note: beforeSend/afterSend are NOT exclusive hooks, but getExclusiveHookProviders
|
|
61
|
-
// only finds exclusive ones. We need all hooks for those names.
|
|
62
|
-
// For now, report what we can from the exclusive hook system.
|
|
63
|
-
// Middleware is non-exclusive so we'd need a different query.
|
|
64
|
-
// TODO: Add getHookProviders() for non-exclusive hooks to the pipeline.
|
|
65
|
-
|
|
66
61
|
return apiSuccess({
|
|
67
62
|
available: emdash.email?.isAvailable() ?? false,
|
|
68
63
|
providers: providers.map((p) => ({
|
|
@@ -8,7 +8,7 @@ import type { APIRoute } from "astro";
|
|
|
8
8
|
|
|
9
9
|
export const prerender = false;
|
|
10
10
|
|
|
11
|
-
import { Role } from "@emdash-cms/auth";
|
|
11
|
+
import { Role, secureCompare } from "@emdash-cms/auth";
|
|
12
12
|
import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
|
|
13
13
|
import { verifyRegistrationResponse, registerPasskey } from "@emdash-cms/auth/passkey";
|
|
14
14
|
|
|
@@ -18,9 +18,10 @@ import { getPublicOrigin } from "#api/public-url.js";
|
|
|
18
18
|
import { setupAdminVerifyBody } from "#api/schemas.js";
|
|
19
19
|
import { createChallengeStore } from "#auth/challenge-store.js";
|
|
20
20
|
import { getPasskeyConfig } from "#auth/passkey-config.js";
|
|
21
|
+
import { SETUP_NONCE_COOKIE } from "#auth/setup-nonce.js";
|
|
21
22
|
import { OptionsRepository } from "#db/repositories/options.js";
|
|
22
23
|
|
|
23
|
-
export const POST: APIRoute = async ({ request, locals }) => {
|
|
24
|
+
export const POST: APIRoute = async ({ cookies, request, locals }) => {
|
|
24
25
|
const { emdash } = locals;
|
|
25
26
|
|
|
26
27
|
if (!emdash?.db) {
|
|
@@ -45,12 +46,35 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
// Get setup state
|
|
48
|
-
const setupState = await options.get
|
|
49
|
+
const setupState = await options.get<{
|
|
50
|
+
step?: string;
|
|
51
|
+
email?: string;
|
|
52
|
+
name?: string | null;
|
|
53
|
+
nonce?: string;
|
|
54
|
+
}>("emdash:setup_state");
|
|
49
55
|
|
|
50
56
|
if (!setupState || setupState.step !== "admin") {
|
|
51
57
|
return apiError("INVALID_STATE", "Invalid setup state. Please restart setup.", 400);
|
|
52
58
|
}
|
|
53
59
|
|
|
60
|
+
// Verify the session nonce. The cookie was minted by POST /setup/admin
|
|
61
|
+
// and stored alongside setup_state; presenting a matching cookie is
|
|
62
|
+
// proof that this verify call comes from the same browser that
|
|
63
|
+
// started the admin step. Constant-time compare to avoid leaking the
|
|
64
|
+
// stored value through timing.
|
|
65
|
+
const cookieNonce = cookies.get(SETUP_NONCE_COOKIE)?.value;
|
|
66
|
+
if (!setupState.nonce || !cookieNonce || !secureCompare(cookieNonce, setupState.nonce)) {
|
|
67
|
+
return apiError(
|
|
68
|
+
"INVALID_STATE",
|
|
69
|
+
"Setup session expired or tampered with. Please restart the admin step.",
|
|
70
|
+
400,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!setupState.email) {
|
|
75
|
+
return apiError("INVALID_STATE", "Invalid setup state. Please restart setup.", 400);
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
// Parse request body
|
|
55
79
|
const body = await parseBody(request, setupAdminVerifyBody);
|
|
56
80
|
if (isParseError(body)) return body;
|
|
@@ -73,7 +97,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
73
97
|
// Create the admin user
|
|
74
98
|
const user = await adapter.createUser({
|
|
75
99
|
email: setupState.email,
|
|
76
|
-
name: setupState.name,
|
|
100
|
+
name: setupState.name ?? null,
|
|
77
101
|
role: Role.ADMIN,
|
|
78
102
|
emailVerified: false, // No email verification for first user
|
|
79
103
|
});
|
|
@@ -84,8 +108,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
84
108
|
// Mark setup as complete
|
|
85
109
|
await options.set("emdash:setup_complete", true);
|
|
86
110
|
|
|
87
|
-
// Clean up setup state
|
|
111
|
+
// Clean up setup state and the session nonce cookie
|
|
88
112
|
await options.delete("emdash:setup_state");
|
|
113
|
+
cookies.delete(SETUP_NONCE_COOKIE, { path: "/_emdash/" });
|
|
89
114
|
|
|
90
115
|
return apiSuccess({
|
|
91
116
|
success: true,
|