emdash 0.6.0 → 0.8.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-Bbq8TCrz.mjs +7 -0
- package/dist/{version-Uaf2ynPX.mjs.map → version-Bbq8TCrz.mjs.map} +1 -1
- package/dist/zod-generator-CpwccCIv.mjs +132 -0
- package/dist/zod-generator-CpwccCIv.mjs.map +1 -0
- package/package.json +18 -5
- 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
|
@@ -8,6 +8,7 @@ import type { APIRoute } from "astro";
|
|
|
8
8
|
|
|
9
9
|
export const prerender = false;
|
|
10
10
|
|
|
11
|
+
import { generateToken } from "@emdash-cms/auth";
|
|
11
12
|
import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
|
|
12
13
|
import { generateRegistrationOptions } from "@emdash-cms/auth/passkey";
|
|
13
14
|
|
|
@@ -17,9 +18,10 @@ import { getPublicOrigin } from "#api/public-url.js";
|
|
|
17
18
|
import { setupAdminBody } from "#api/schemas.js";
|
|
18
19
|
import { createChallengeStore } from "#auth/challenge-store.js";
|
|
19
20
|
import { getPasskeyConfig } from "#auth/passkey-config.js";
|
|
21
|
+
import { SETUP_NONCE_COOKIE, SETUP_NONCE_MAX_AGE_SECONDS } from "#auth/setup-nonce.js";
|
|
20
22
|
import { OptionsRepository } from "#db/repositories/options.js";
|
|
21
23
|
|
|
22
|
-
export const POST: APIRoute = async ({ request, locals }) => {
|
|
24
|
+
export const POST: APIRoute = async ({ cookies, request, locals }) => {
|
|
23
25
|
const { emdash } = locals;
|
|
24
26
|
|
|
25
27
|
if (!emdash?.db) {
|
|
@@ -47,12 +49,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
47
49
|
const body = await parseBody(request, setupAdminBody);
|
|
48
50
|
if (isParseError(body)) return body;
|
|
49
51
|
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
// Preserve title/tagline from step 1 by reading existing setup state
|
|
53
|
+
// before we overwrite it below.
|
|
54
|
+
const existingState = await options.get<Record<string, unknown>>("emdash:setup_state");
|
|
55
|
+
|
|
56
|
+
// Mint a fresh session nonce. This binds the follow-up
|
|
57
|
+
// /setup/admin/verify call to the same browser that made this
|
|
58
|
+
// request, so an unauthenticated attacker on another host cannot
|
|
59
|
+
// substitute their own email into the setup state during the
|
|
60
|
+
// setup window. Rotates on every call so a legitimate retry
|
|
61
|
+
// always gets a working session.
|
|
62
|
+
const nonce = generateToken();
|
|
56
63
|
|
|
57
64
|
// Get passkey config
|
|
58
65
|
const url = new URL(request.url);
|
|
@@ -78,12 +85,35 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
78
85
|
challengeStore,
|
|
79
86
|
);
|
|
80
87
|
|
|
81
|
-
// Store the
|
|
88
|
+
// Store the nonce alongside the rest of the setup state, preserving
|
|
89
|
+
// title/tagline from step 1. The verify endpoint will constant-time
|
|
90
|
+
// compare the nonce with the incoming cookie.
|
|
82
91
|
await options.set("emdash:setup_state", {
|
|
92
|
+
...existingState,
|
|
83
93
|
step: "admin",
|
|
84
94
|
email: body.email.toLowerCase(),
|
|
85
95
|
name: body.name || null,
|
|
86
96
|
tempUserId: tempUser.id,
|
|
97
|
+
nonce,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// HttpOnly + SameSite=Strict + path-scoped. The cookie must not be
|
|
101
|
+
// accessible to JS (nothing in the admin UI needs to read it) and
|
|
102
|
+
// must not be sent on cross-site navigations. The /_emdash/ path
|
|
103
|
+
// scope keeps it away from user-authored frontend code.
|
|
104
|
+
//
|
|
105
|
+
// Derive `secure` from the public origin, not the internal request
|
|
106
|
+
// URL. Behind a TLS-terminating reverse proxy the internal hop is
|
|
107
|
+
// often `http:` while the browser-facing origin is `https:` —
|
|
108
|
+
// using `url.protocol` there would drop the Secure flag on a
|
|
109
|
+
// sensitive cookie over the public HTTPS connection.
|
|
110
|
+
const publicOrigin = new URL(siteUrl);
|
|
111
|
+
cookies.set(SETUP_NONCE_COOKIE, nonce, {
|
|
112
|
+
path: "/_emdash/",
|
|
113
|
+
httpOnly: true,
|
|
114
|
+
sameSite: "strict",
|
|
115
|
+
secure: publicOrigin.protocol === "https:",
|
|
116
|
+
maxAge: SETUP_NONCE_MAX_AGE_SECONDS,
|
|
87
117
|
});
|
|
88
118
|
|
|
89
119
|
return apiSuccess({
|
|
@@ -81,7 +81,7 @@ export const POST: APIRoute = async ({ request, url, locals }) => {
|
|
|
81
81
|
|
|
82
82
|
// 5. Store setup state
|
|
83
83
|
// In external auth mode, mark setup complete immediately (first user to login becomes admin)
|
|
84
|
-
//
|
|
84
|
+
// Otherwise, setup_complete is set after admin user is created (passkey or auth provider)
|
|
85
85
|
const authMode = getAuthMode(emdash.config);
|
|
86
86
|
const useExternalAuth = authMode.type === "external";
|
|
87
87
|
|
|
@@ -89,9 +89,12 @@ export const POST: APIRoute = async ({ request, url, locals }) => {
|
|
|
89
89
|
const options = new OptionsRepository(emdash.db);
|
|
90
90
|
|
|
91
91
|
// Store the canonical site URL from the setup request.
|
|
92
|
-
//
|
|
92
|
+
// Write-once at the DB level so concurrent setup POSTs can't both
|
|
93
|
+
// observe an empty value and race to write. A spoofed Host header
|
|
94
|
+
// on a later call during the wizard window must not be able to
|
|
95
|
+
// replace the first value.
|
|
93
96
|
const siteUrl = getPublicOrigin(url, emdash.config);
|
|
94
|
-
await options.
|
|
97
|
+
await options.setIfAbsent("emdash:site_url", siteUrl);
|
|
95
98
|
|
|
96
99
|
if (useExternalAuth) {
|
|
97
100
|
// External auth mode: mark setup complete now
|
|
@@ -102,7 +105,7 @@ export const POST: APIRoute = async ({ request, url, locals }) => {
|
|
|
102
105
|
await options.set("emdash:site_tagline", body.tagline);
|
|
103
106
|
}
|
|
104
107
|
} else {
|
|
105
|
-
// Passkey mode: store state for next step (admin creation)
|
|
108
|
+
// Passkey/provider mode: store state for next step (admin creation)
|
|
106
109
|
await options.set("emdash:setup_state", {
|
|
107
110
|
step: "site_complete",
|
|
108
111
|
title: body.title,
|
|
@@ -91,7 +91,7 @@ export const GET: APIRoute = async ({ locals }) => {
|
|
|
91
91
|
const authMode = getAuthMode(emdash.config);
|
|
92
92
|
const useExternalAuth = authMode.type === "external";
|
|
93
93
|
|
|
94
|
-
// In external auth mode, setup is complete if flag is set (no users required initially)
|
|
94
|
+
// In external auth mode (not atproto), setup is complete if flag is set (no users required initially)
|
|
95
95
|
if (useExternalAuth && isComplete) {
|
|
96
96
|
return apiSuccess({
|
|
97
97
|
needsSetup: false,
|
|
@@ -106,6 +106,8 @@ export const GET: APIRoute = async ({ locals }) => {
|
|
|
106
106
|
description: seed.meta?.description || "",
|
|
107
107
|
collections: seed.collections?.length || 0,
|
|
108
108
|
hasContent: !!(seed.content && Object.keys(seed.content).length > 0),
|
|
109
|
+
title: seed.settings?.title,
|
|
110
|
+
tagline: seed.settings?.tagline,
|
|
109
111
|
}
|
|
110
112
|
: null;
|
|
111
113
|
|
|
@@ -11,6 +11,8 @@ import { requirePerm } from "#api/authorize.js";
|
|
|
11
11
|
import { apiError, apiSuccess, handleError } from "#api/error.js";
|
|
12
12
|
import { isParseError, parseBody } from "#api/parse.js";
|
|
13
13
|
import { updateWidgetBody } from "#api/schemas.js";
|
|
14
|
+
import { rowToWidget } from "#widgets/index.js";
|
|
15
|
+
import type { WidgetRow } from "#widgets/types.js";
|
|
14
16
|
|
|
15
17
|
export const prerender = false;
|
|
16
18
|
|
|
@@ -73,10 +75,11 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
|
|
|
73
75
|
const widget = await db
|
|
74
76
|
.selectFrom("_emdash_widgets")
|
|
75
77
|
.selectAll()
|
|
78
|
+
.$castTo<WidgetRow>()
|
|
76
79
|
.where("id", "=", id)
|
|
77
80
|
.executeTakeFirstOrThrow();
|
|
78
81
|
|
|
79
|
-
return apiSuccess(widget);
|
|
82
|
+
return apiSuccess(rowToWidget(widget));
|
|
80
83
|
} catch (error) {
|
|
81
84
|
return handleError(error, "Failed to update widget", "WIDGET_UPDATE_ERROR");
|
|
82
85
|
}
|
|
@@ -11,6 +11,8 @@ import { requirePerm } from "#api/authorize.js";
|
|
|
11
11
|
import { apiError, apiSuccess, handleError } from "#api/error.js";
|
|
12
12
|
import { isParseError, parseBody } from "#api/parse.js";
|
|
13
13
|
import { createWidgetBody } from "#api/schemas.js";
|
|
14
|
+
import { rowToWidget } from "#widgets/index.js";
|
|
15
|
+
import type { WidgetRow } from "#widgets/types.js";
|
|
14
16
|
|
|
15
17
|
export const prerender = false;
|
|
16
18
|
|
|
@@ -70,10 +72,11 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
|
|
|
70
72
|
const widget = await db
|
|
71
73
|
.selectFrom("_emdash_widgets")
|
|
72
74
|
.selectAll()
|
|
75
|
+
.$castTo<WidgetRow>()
|
|
73
76
|
.where("id", "=", id)
|
|
74
77
|
.executeTakeFirstOrThrow();
|
|
75
78
|
|
|
76
|
-
return apiSuccess(widget, 201);
|
|
79
|
+
return apiSuccess(rowToWidget(widget), 201);
|
|
77
80
|
} catch (error) {
|
|
78
81
|
return handleError(error, "Failed to create widget", "WIDGET_CREATE_ERROR");
|
|
79
82
|
}
|
|
@@ -9,6 +9,8 @@ import type { APIRoute } from "astro";
|
|
|
9
9
|
|
|
10
10
|
import { requirePerm } from "#api/authorize.js";
|
|
11
11
|
import { apiError, apiSuccess, handleError } from "#api/error.js";
|
|
12
|
+
import { rowToWidget } from "#widgets/index.js";
|
|
13
|
+
import type { WidgetRow } from "#widgets/types.js";
|
|
12
14
|
|
|
13
15
|
export const prerender = false;
|
|
14
16
|
|
|
@@ -40,13 +42,14 @@ export const GET: APIRoute = async ({ params, locals }) => {
|
|
|
40
42
|
const widgets = await db
|
|
41
43
|
.selectFrom("_emdash_widgets")
|
|
42
44
|
.selectAll()
|
|
45
|
+
.$castTo<WidgetRow>()
|
|
43
46
|
.where("area_id", "=", area.id)
|
|
44
47
|
.orderBy("sort_order", "asc")
|
|
45
48
|
.execute();
|
|
46
49
|
|
|
47
50
|
return apiSuccess({
|
|
48
51
|
...area,
|
|
49
|
-
widgets,
|
|
52
|
+
widgets: widgets.map((row) => rowToWidget(row)),
|
|
50
53
|
});
|
|
51
54
|
} catch (error) {
|
|
52
55
|
return handleError(error, "Failed to fetch widget area", "WIDGET_AREA_GET_ERROR");
|
|
@@ -12,6 +12,8 @@ import { requirePerm } from "#api/authorize.js";
|
|
|
12
12
|
import { apiError, apiSuccess, handleError } from "#api/error.js";
|
|
13
13
|
import { isParseError, parseBody } from "#api/parse.js";
|
|
14
14
|
import { createWidgetAreaBody } from "#api/schemas.js";
|
|
15
|
+
import { rowToWidget } from "#widgets/index.js";
|
|
16
|
+
import type { WidgetRow } from "#widgets/types.js";
|
|
15
17
|
|
|
16
18
|
export const prerender = false;
|
|
17
19
|
|
|
@@ -35,13 +37,14 @@ export const GET: APIRoute = async ({ locals }) => {
|
|
|
35
37
|
const widgets = await db
|
|
36
38
|
.selectFrom("_emdash_widgets")
|
|
37
39
|
.selectAll()
|
|
40
|
+
.$castTo<WidgetRow>()
|
|
38
41
|
.where("area_id", "=", area.id)
|
|
39
42
|
.orderBy("sort_order", "asc")
|
|
40
43
|
.execute();
|
|
41
44
|
|
|
42
45
|
return {
|
|
43
46
|
...area,
|
|
44
|
-
widgets,
|
|
47
|
+
widgets: widgets.map((row) => rowToWidget(row)),
|
|
45
48
|
widgetCount: widgets.length,
|
|
46
49
|
};
|
|
47
50
|
}),
|
package/src/astro/types.ts
CHANGED
|
@@ -142,6 +142,15 @@ export interface EmDashManifest {
|
|
|
142
142
|
* When true, the admin UI can show marketplace browse/install features.
|
|
143
143
|
*/
|
|
144
144
|
marketplace?: boolean;
|
|
145
|
+
/**
|
|
146
|
+
* Admin branding overrides for white-labeling.
|
|
147
|
+
* Set via the `admin` config in `astro.config.mjs`.
|
|
148
|
+
*/
|
|
149
|
+
admin?: {
|
|
150
|
+
logo?: string;
|
|
151
|
+
siteName?: string;
|
|
152
|
+
favicon?: string;
|
|
153
|
+
};
|
|
145
154
|
}
|
|
146
155
|
|
|
147
156
|
/**
|
|
@@ -339,6 +348,7 @@ export interface EmDashHandlers {
|
|
|
339
348
|
// Direct access to storage and database for advanced use cases
|
|
340
349
|
storage: import("../index.js").Storage | null;
|
|
341
350
|
db: Kysely<import("../index.js").Database>;
|
|
351
|
+
getPublicMediaUrl?: (storageKey: string) => string;
|
|
342
352
|
|
|
343
353
|
// Hook pipeline for plugin integrations
|
|
344
354
|
hooks: import("../plugins/hooks.js").HookPipeline;
|
|
@@ -371,4 +381,12 @@ export interface EmDashHandlers {
|
|
|
371
381
|
collectPageFragments: (
|
|
372
382
|
page: import("../plugins/types.js").PublicPageContext,
|
|
373
383
|
) => Promise<import("../plugins/types.js").PageFragmentContribution[]>;
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Lazy search index health check. Search routes call this before
|
|
387
|
+
* querying so a crash-corrupted index gets repaired on first use
|
|
388
|
+
* rather than stalling cold start. Optional because it's only
|
|
389
|
+
* meaningful when an FTS5-capable runtime is wired in.
|
|
390
|
+
*/
|
|
391
|
+
ensureSearchHealthy?: () => Promise<void>;
|
|
374
392
|
}
|
package/src/auth/mode.ts
CHANGED
|
@@ -6,9 +6,21 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { EmDashConfig } from "../astro/integration/runtime.js";
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
AuthDescriptor,
|
|
11
|
+
AuthProviderDescriptor,
|
|
12
|
+
AuthRouteDescriptor,
|
|
13
|
+
AuthResult,
|
|
14
|
+
ExternalAuthConfig,
|
|
15
|
+
} from "./types.js";
|
|
10
16
|
|
|
11
|
-
export type {
|
|
17
|
+
export type {
|
|
18
|
+
AuthDescriptor,
|
|
19
|
+
AuthProviderDescriptor,
|
|
20
|
+
AuthRouteDescriptor,
|
|
21
|
+
AuthResult,
|
|
22
|
+
ExternalAuthConfig,
|
|
23
|
+
};
|
|
12
24
|
|
|
13
25
|
/**
|
|
14
26
|
* Passkey auth mode (default)
|
|
@@ -59,7 +71,7 @@ export function getAuthMode(
|
|
|
59
71
|
): AuthMode {
|
|
60
72
|
const auth = config?.auth;
|
|
61
73
|
|
|
62
|
-
// Check for AuthDescriptor (
|
|
74
|
+
// Check for AuthDescriptor (transparent external auth like Cloudflare Access)
|
|
63
75
|
if (auth && "entrypoint" in auth && auth.entrypoint) {
|
|
64
76
|
return {
|
|
65
77
|
type: "external",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub OAuth Admin Components
|
|
3
|
+
*
|
|
4
|
+
* LoginButton for the login page, rendered via the auth provider virtual module.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { LinkButton } from "@cloudflare/kumo";
|
|
8
|
+
import * as React from "react";
|
|
9
|
+
|
|
10
|
+
function GitHubIcon({ className }: { className?: string }) {
|
|
11
|
+
return (
|
|
12
|
+
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
|
13
|
+
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
14
|
+
</svg>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function LoginButton() {
|
|
19
|
+
return (
|
|
20
|
+
<LinkButton
|
|
21
|
+
href="/_emdash/api/auth/oauth/github"
|
|
22
|
+
variant="outline"
|
|
23
|
+
className="w-full justify-center"
|
|
24
|
+
>
|
|
25
|
+
<GitHubIcon className="h-5 w-5" />
|
|
26
|
+
<span>GitHub</span>
|
|
27
|
+
</LinkButton>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub OAuth Auth Provider
|
|
3
|
+
*
|
|
4
|
+
* Returns an AuthProviderDescriptor for GitHub OAuth login.
|
|
5
|
+
* Credentials are read from environment variables at runtime.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { github } from "emdash/auth/providers/github";
|
|
10
|
+
*
|
|
11
|
+
* emdash({
|
|
12
|
+
* authProviders: [github()],
|
|
13
|
+
* })
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { AuthProviderDescriptor } from "../types.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Configure GitHub OAuth as an auth provider.
|
|
21
|
+
*
|
|
22
|
+
* Requires `EMDASH_OAUTH_GITHUB_CLIENT_ID` and `EMDASH_OAUTH_GITHUB_CLIENT_SECRET`
|
|
23
|
+
* (or `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`) environment variables.
|
|
24
|
+
*/
|
|
25
|
+
export function github(): AuthProviderDescriptor {
|
|
26
|
+
return {
|
|
27
|
+
id: "github",
|
|
28
|
+
label: "GitHub",
|
|
29
|
+
adminEntry: "emdash/auth/providers/github-admin",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google OAuth Admin Components
|
|
3
|
+
*
|
|
4
|
+
* LoginButton for the login page, rendered via the auth provider virtual module.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { LinkButton } from "@cloudflare/kumo";
|
|
8
|
+
import * as React from "react";
|
|
9
|
+
|
|
10
|
+
function GoogleIcon({ className }: { className?: string }) {
|
|
11
|
+
return (
|
|
12
|
+
<svg className={className} viewBox="0 0 24 24">
|
|
13
|
+
<path
|
|
14
|
+
fill="#4285F4"
|
|
15
|
+
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
|
16
|
+
/>
|
|
17
|
+
<path
|
|
18
|
+
fill="#34A853"
|
|
19
|
+
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
20
|
+
/>
|
|
21
|
+
<path
|
|
22
|
+
fill="#FBBC05"
|
|
23
|
+
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
|
24
|
+
/>
|
|
25
|
+
<path
|
|
26
|
+
fill="#EA4335"
|
|
27
|
+
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
28
|
+
/>
|
|
29
|
+
</svg>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function LoginButton() {
|
|
34
|
+
return (
|
|
35
|
+
<LinkButton
|
|
36
|
+
href="/_emdash/api/auth/oauth/google"
|
|
37
|
+
variant="outline"
|
|
38
|
+
className="w-full justify-center"
|
|
39
|
+
>
|
|
40
|
+
<GoogleIcon className="h-5 w-5" />
|
|
41
|
+
<span>Google</span>
|
|
42
|
+
</LinkButton>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google OAuth Auth Provider
|
|
3
|
+
*
|
|
4
|
+
* Returns an AuthProviderDescriptor for Google OAuth login.
|
|
5
|
+
* Credentials are read from environment variables at runtime.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { google } from "emdash/auth/providers/google";
|
|
10
|
+
*
|
|
11
|
+
* emdash({
|
|
12
|
+
* authProviders: [google()],
|
|
13
|
+
* })
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { AuthProviderDescriptor } from "../types.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Configure Google OAuth as an auth provider.
|
|
21
|
+
*
|
|
22
|
+
* Requires `EMDASH_OAUTH_GOOGLE_CLIENT_ID` and `EMDASH_OAUTH_GOOGLE_CLIENT_SECRET`
|
|
23
|
+
* (or `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`) environment variables.
|
|
24
|
+
*/
|
|
25
|
+
export function google(): AuthProviderDescriptor {
|
|
26
|
+
return {
|
|
27
|
+
id: "google",
|
|
28
|
+
label: "Google",
|
|
29
|
+
adminEntry: "emdash/auth/providers/google-admin",
|
|
30
|
+
};
|
|
31
|
+
}
|
package/src/auth/rate-limit.ts
CHANGED
|
@@ -100,44 +100,72 @@ export function rateLimitResponse(retryAfterSeconds: number): Response {
|
|
|
100
100
|
*
|
|
101
101
|
* Resolution order:
|
|
102
102
|
* 1. `CF-Connecting-IP` — trusted only when the Cloudflare `cf` object is
|
|
103
|
-
* present
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* 3.
|
|
103
|
+
* present. CF edge overwrites any client-supplied value, so this is the
|
|
104
|
+
* cryptographically trustworthy path on Workers. Operator-declared
|
|
105
|
+
* trusted headers cannot override it.
|
|
106
|
+
* 2. `X-Forwarded-For` (first entry) — trusted only when the `cf` object
|
|
107
|
+
* is present (CF sets this reliably).
|
|
108
|
+
* 3. Operator-declared trusted proxy headers (ordered list) — used as a
|
|
109
|
+
* fallback for non-CF deployments behind a reverse proxy the operator
|
|
110
|
+
* controls. Also applies as a fill-in on CF when the CF headers are
|
|
111
|
+
* absent (e.g. internal cron handlers).
|
|
112
|
+
* 4. `null` — no trusted IP available. Callers must handle this gracefully
|
|
109
113
|
* (e.g. skip rate limiting).
|
|
110
114
|
*
|
|
115
|
+
* Pass `trustedHeaders` from `getTrustedProxyHeaders(emdash.config)` so
|
|
116
|
+
* self-hosted non-CF deployments can opt into reading a specific header.
|
|
117
|
+
*
|
|
111
118
|
* Aligned with `extractRequestMeta` in `plugins/request-meta.ts`.
|
|
112
119
|
*/
|
|
113
|
-
export function getClientIp(request: Request): string | null {
|
|
120
|
+
export function getClientIp(request: Request, trustedHeaders: string[] = []): string | null {
|
|
114
121
|
const headers = request.headers;
|
|
115
122
|
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- CF Workers runtime shape
|
|
116
123
|
const cf = (request as unknown as { cf?: Record<string, unknown> }).cf;
|
|
117
124
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
125
|
+
// On Cloudflare, prefer the cryptographically trustworthy headers. An
|
|
126
|
+
// attacker can't spoof these — the CF edge strips/overwrites them.
|
|
127
|
+
if (cf) {
|
|
128
|
+
const cfIp = headers.get("cf-connecting-ip")?.trim();
|
|
129
|
+
if (cfIp && IP_PATTERN.test(cfIp)) {
|
|
130
|
+
return cfIp;
|
|
131
|
+
}
|
|
122
132
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
133
|
+
const xff = headers.get("x-forwarded-for");
|
|
134
|
+
if (xff) {
|
|
135
|
+
const first = xff.split(",")[0]?.trim();
|
|
136
|
+
if (first && IP_PATTERN.test(first)) {
|
|
137
|
+
return first;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
127
140
|
}
|
|
128
141
|
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
if (
|
|
134
|
-
return first;
|
|
135
|
-
}
|
|
142
|
+
// Fall through to operator-declared trusted headers. On CF this fills
|
|
143
|
+
// in when the CF headers are absent; off-CF it's the primary source.
|
|
144
|
+
for (const name of trustedHeaders) {
|
|
145
|
+
const value = readIpFromHeader(headers, name);
|
|
146
|
+
if (value) return value;
|
|
136
147
|
}
|
|
137
148
|
|
|
138
149
|
return null;
|
|
139
150
|
}
|
|
140
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Read an IP from an operator-declared trusted header. XFF-style headers
|
|
154
|
+
* are parsed as comma-separated lists and the first entry is used.
|
|
155
|
+
*/
|
|
156
|
+
function readIpFromHeader(headers: Headers, name: string): string | null {
|
|
157
|
+
const value = headers.get(name);
|
|
158
|
+
if (!value) return null;
|
|
159
|
+
if (name.toLowerCase().endsWith("forwarded-for")) {
|
|
160
|
+
const first = value.split(",")[0]?.trim();
|
|
161
|
+
if (!first) return null;
|
|
162
|
+
return IP_PATTERN.test(first) ? first : null;
|
|
163
|
+
}
|
|
164
|
+
const trimmed = value.trim();
|
|
165
|
+
if (!trimmed) return null;
|
|
166
|
+
return IP_PATTERN.test(trimmed) ? trimmed : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
141
169
|
/**
|
|
142
170
|
* Delete expired rate limit entries.
|
|
143
171
|
*
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session binding for the first-setup admin-creation flow.
|
|
3
|
+
*
|
|
4
|
+
* Shared constants for the nonce cookie that ties /_emdash/api/setup/admin
|
|
5
|
+
* and /_emdash/api/setup/admin/verify to the same browser. Without this
|
|
6
|
+
* binding, any unauthenticated caller could POST /setup/admin during the
|
|
7
|
+
* setup window and substitute their own email into the stored setup state
|
|
8
|
+
* before the legitimate admin completes passkey verification.
|
|
9
|
+
*
|
|
10
|
+
* Implementation lives in the two route handlers; this module is just
|
|
11
|
+
* the name / lifetime so both ends agree.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Cookie name carrying the setup-admin session nonce. */
|
|
15
|
+
export const SETUP_NONCE_COOKIE = "emdash_setup_nonce";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Cookie max-age in seconds. One hour is plenty of time to complete
|
|
19
|
+
* a passkey registration; if the user lingers longer the admin step
|
|
20
|
+
* can simply be retried.
|
|
21
|
+
*/
|
|
22
|
+
export const SETUP_NONCE_MAX_AGE_SECONDS = 60 * 60;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the list of client-IP headers the operator trusts.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order:
|
|
5
|
+
* 1. `config.trustedProxyHeaders` — explicit opt-in via astro.config.mjs.
|
|
6
|
+
* An empty array is respected (means "trust nothing, ignore env").
|
|
7
|
+
* 2. `EMDASH_TRUSTED_PROXY_HEADERS` env var — comma-separated header names.
|
|
8
|
+
* 3. `[]` — default, no trusted headers.
|
|
9
|
+
*
|
|
10
|
+
* Operators must only set this when they control the reverse proxy.
|
|
11
|
+
* Untrusted clients can set any header they like; trusting headers from
|
|
12
|
+
* an open network defeats rate limiting.
|
|
13
|
+
*
|
|
14
|
+
* Header names are returned lowercased because HTTP header lookups are
|
|
15
|
+
* case-insensitive.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { EmDashConfig } from "../astro/integration/runtime.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* RFC 7230 token — valid characters for an HTTP header name. Invalid names
|
|
22
|
+
* passed to `Headers.get()` throw a TypeError at runtime, which would
|
|
23
|
+
* otherwise surface as a 500 from every auth route.
|
|
24
|
+
*/
|
|
25
|
+
const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9a-z]+$/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Normalise a list of header names the way both the config path and any
|
|
29
|
+
* caller passing a pre-resolved list should do: trim, lowercase, drop
|
|
30
|
+
* empty, drop anything that isn't a valid RFC 7230 token. Invalid names
|
|
31
|
+
* would crash `Headers.get()` at runtime.
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeTrustedHeaders(names: readonly string[]): string[] {
|
|
34
|
+
return names
|
|
35
|
+
.map((h) => h.trim().toLowerCase())
|
|
36
|
+
.filter((h) => h.length > 0 && HEADER_NAME_PATTERN.test(h));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isValidHeaderName(name: string): boolean {
|
|
40
|
+
return HEADER_NAME_PATTERN.test(name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Cache for the env-derived value. `null` means "not yet parsed". */
|
|
44
|
+
let _envCache: string[] | null = null;
|
|
45
|
+
|
|
46
|
+
/** Test-only: clear the env cache so a fresh value is read on next call. */
|
|
47
|
+
export function _resetTrustedProxyHeadersCache(): void {
|
|
48
|
+
_envCache = null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getEnvTrustedHeaders(): string[] {
|
|
52
|
+
if (_envCache !== null) return _envCache;
|
|
53
|
+
let raw: string | undefined;
|
|
54
|
+
try {
|
|
55
|
+
// Prefer process.env so SSR/container deployments can override this
|
|
56
|
+
// value at runtime (Vite/Astro inline import.meta.env at build time,
|
|
57
|
+
// which locks the value into the bundle). Fall back to import.meta.env
|
|
58
|
+
// for bundler-managed environments where process.env isn't populated.
|
|
59
|
+
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- import.meta.env shape varies by bundler
|
|
60
|
+
const importMetaEnv = (import.meta as unknown as { env?: Record<string, string | undefined> })
|
|
61
|
+
.env;
|
|
62
|
+
raw =
|
|
63
|
+
(typeof process !== "undefined" ? process.env?.EMDASH_TRUSTED_PROXY_HEADERS : undefined) ||
|
|
64
|
+
importMetaEnv?.EMDASH_TRUSTED_PROXY_HEADERS;
|
|
65
|
+
} catch {
|
|
66
|
+
raw = undefined;
|
|
67
|
+
}
|
|
68
|
+
if (!raw) {
|
|
69
|
+
_envCache = [];
|
|
70
|
+
return _envCache;
|
|
71
|
+
}
|
|
72
|
+
_envCache = raw
|
|
73
|
+
.split(",")
|
|
74
|
+
.map((s) => s.trim().toLowerCase())
|
|
75
|
+
.filter((s) => s.length > 0 && isValidHeaderName(s));
|
|
76
|
+
return _envCache;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Return the lowercased list of headers to trust for client-IP resolution.
|
|
81
|
+
*
|
|
82
|
+
* When `config?.trustedProxyHeaders` is explicitly set (even to `[]`), it
|
|
83
|
+
* wins. Otherwise fall through to the env var, then to `[]`.
|
|
84
|
+
*/
|
|
85
|
+
export function getTrustedProxyHeaders(config: EmDashConfig | null | undefined): string[] {
|
|
86
|
+
if (config && config.trustedProxyHeaders !== undefined) {
|
|
87
|
+
return config.trustedProxyHeaders
|
|
88
|
+
.map((h) => h.trim().toLowerCase())
|
|
89
|
+
.filter((h) => h.length > 0 && isValidHeaderName(h));
|
|
90
|
+
}
|
|
91
|
+
return getEnvTrustedHeaders();
|
|
92
|
+
}
|