emdash 0.4.0 → 0.6.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-C2BzVy0p.d.mts → adapters-Di31kZ28.d.mts} +16 -1
- package/dist/adapters-Di31kZ28.d.mts.map +1 -0
- package/dist/{apply-Cma_PiF6.mjs → apply-B4MsLM-w.mjs} +27 -12
- package/dist/apply-B4MsLM-w.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 +208 -34
- 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 +34 -9
- package/dist/astro/middleware/auth.mjs.map +1 -1
- package/dist/astro/middleware/redirect.mjs +1 -1
- package/dist/astro/middleware/request-context.d.mts.map +1 -1
- package/dist/astro/middleware/request-context.mjs +5 -3
- 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 +460 -180
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +8 -8
- package/dist/{byline-WuOq9MFJ.mjs → byline-C4OVd8b3.mjs} +3 -19
- package/dist/byline-C4OVd8b3.mjs.map +1 -0
- package/dist/{bylines-C_Wsnz4L.mjs → bylines-hPTW79hw.mjs} +20 -33
- package/dist/bylines-hPTW79hw.mjs.map +1 -0
- package/dist/{cache-E3Dts-yT.mjs → cache-BkKBuIvS.mjs} +1 -1
- package/dist/{cache-E3Dts-yT.mjs.map → cache-BkKBuIvS.mjs.map} +1 -1
- package/dist/chunks-HGz06Soa.mjs +19 -0
- package/dist/chunks-HGz06Soa.mjs.map +1 -0
- package/dist/cli/index.mjs +9 -8
- 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/{config-DkxPrM9l.mjs → config-BXwuX8Bx.mjs} +1 -1
- package/dist/{config-DkxPrM9l.mjs.map → config-BXwuX8Bx.mjs.map} +1 -1
- package/dist/{connection-B4zVnQIa.mjs → connection-2igzM-AT.mjs} +19 -2
- package/dist/connection-2igzM-AT.mjs.map +1 -0
- package/dist/database/instrumentation.d.mts +45 -0
- package/dist/database/instrumentation.d.mts.map +1 -0
- package/dist/database/instrumentation.mjs +61 -0
- package/dist/database/instrumentation.mjs.map +1 -0
- package/dist/db/index.d.mts +3 -3
- package/dist/db/index.mjs.map +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 +41 -0
- package/dist/db-errors-D0UT85nC.mjs.map +1 -0
- package/dist/{default-PUx9RK6u.mjs → default-CME5YdZ3.mjs} +1 -1
- package/dist/{default-PUx9RK6u.mjs.map → default-CME5YdZ3.mjs.map} +1 -1
- package/dist/{error-HBeQbVhV.mjs → error-CiYn9yDu.mjs} +1 -1
- package/dist/{error-HBeQbVhV.mjs.map → error-CiYn9yDu.mjs.map} +1 -1
- package/dist/{index-CRg3PWfZ.d.mts → index-BYv0mB9g.d.mts} +135 -19
- package/dist/index-BYv0mB9g.d.mts.map +1 -0
- package/dist/index.d.mts +11 -11
- package/dist/index.mjs +20 -18
- package/dist/{load-BhSSm-TS.mjs → load-CBcmDIot.mjs} +1 -1
- package/dist/{load-BhSSm-TS.mjs.map → load-CBcmDIot.mjs.map} +1 -1
- package/dist/{loader-BYzwzORf.mjs → loader-DeiBJEMe.mjs} +18 -12
- package/dist/loader-DeiBJEMe.mjs.map +1 -0
- package/dist/{manifest-schema-BsXINkQD.mjs → manifest-schema-V30qsMft.mjs} +1 -1
- package/dist/{manifest-schema-BsXINkQD.mjs.map → manifest-schema-V30qsMft.mjs.map} +1 -1
- package/dist/media/index.d.mts +1 -1
- package/dist/media/index.mjs +1 -1
- package/dist/media/local-runtime.d.mts +7 -7
- package/dist/{mode-CyPLdO3C.mjs → mode-CpNnGkPz.mjs} +1 -1
- package/dist/{mode-CyPLdO3C.mjs.map → mode-CpNnGkPz.mjs.map} +1 -1
- package/dist/page/index.d.mts +11 -2
- package/dist/page/index.d.mts.map +1 -1
- package/dist/page/index.mjs +23 -1
- package/dist/page/index.mjs.map +1 -1
- package/dist/{placeholder-DntBEQo7.mjs → placeholder-C-fk5hYI.mjs} +1 -1
- package/dist/{placeholder-DntBEQo7.mjs.map → placeholder-C-fk5hYI.mjs.map} +1 -1
- package/dist/{placeholder-BBCtpTES.d.mts → placeholder-tzpqGWII.d.mts} +1 -1
- package/dist/{placeholder-BBCtpTES.d.mts.map → placeholder-tzpqGWII.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-B6Vu0d2i.mjs → query-Bk_3vKvU.mjs} +78 -11
- package/dist/query-Bk_3vKvU.mjs.map +1 -0
- package/dist/{registry-BgnP3ysR.mjs → registry-Ci3WxVAr.mjs} +133 -97
- package/dist/registry-Ci3WxVAr.mjs.map +1 -0
- package/dist/request-cache-DiR961CV.mjs +79 -0
- package/dist/request-cache-DiR961CV.mjs.map +1 -0
- package/dist/request-context.d.mts +19 -16
- package/dist/request-context.d.mts.map +1 -1
- package/dist/request-context.mjs.map +1 -1
- package/dist/{runner-DYv3rX8P.d.mts → runner-Fl2NcUUz.d.mts} +2 -2
- package/dist/{runner-DYv3rX8P.d.mts.map → runner-Fl2NcUUz.d.mts.map} +1 -1
- package/dist/runtime.d.mts +6 -6
- package/dist/runtime.mjs +1 -1
- package/dist/{search-B5p9D36n.mjs → search-DI4bM2w9.mjs} +110 -209
- package/dist/search-DI4bM2w9.mjs.map +1 -0
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +8 -7
- 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.mjs +1 -1
- package/dist/taxonomies-DbrKzDju.mjs +308 -0
- package/dist/taxonomies-DbrKzDju.mjs.map +1 -0
- package/dist/{tokens-DKHiCYCB.mjs → tokens-BFPFx3CA.mjs} +1 -1
- package/dist/{tokens-DKHiCYCB.mjs.map → tokens-BFPFx3CA.mjs.map} +1 -1
- package/dist/{transport-BtcQ-Z7T.mjs → transport-BykRfpyy.mjs} +1 -1
- package/dist/{transport-BtcQ-Z7T.mjs.map → transport-BykRfpyy.mjs.map} +1 -1
- package/dist/{transport-CKQA_G44.d.mts → transport-H4Iwx7tC.d.mts} +1 -1
- package/dist/{transport-CKQA_G44.d.mts.map → transport-H4Iwx7tC.d.mts.map} +1 -1
- package/dist/{types-BmkQR1En.d.mts → types-6CUZRrZP.d.mts} +1 -1
- package/dist/{types-BmkQR1En.d.mts.map → types-6CUZRrZP.d.mts.map} +1 -1
- package/dist/{types-B6BzlZxx.d.mts → types-8xrvl_68.d.mts} +1 -1
- package/dist/{types-B6BzlZxx.d.mts.map → types-8xrvl_68.d.mts.map} +1 -1
- package/dist/{types-Dz9_WMS6.mjs → types-BH2L167P.mjs} +1 -1
- package/dist/{types-Dz9_WMS6.mjs.map → types-BH2L167P.mjs.map} +1 -1
- package/dist/{types-DNZpaCBk.d.mts → types-CFWjXmus.d.mts} +1 -1
- package/dist/{types-DNZpaCBk.d.mts.map → types-CFWjXmus.d.mts.map} +1 -1
- package/dist/{types-gLYVCXCQ.d.mts → types-CnZYHyLW.d.mts} +55 -5
- package/dist/types-CnZYHyLW.d.mts.map +1 -0
- package/dist/{types-xxCWI3j0.mjs → types-DDS4MxsT.mjs} +11 -3
- package/dist/types-DDS4MxsT.mjs.map +1 -0
- package/dist/{types-BYWYxLcp.d.mts → types-DgrIP0tF.d.mts} +9 -2
- package/dist/types-DgrIP0tF.d.mts.map +1 -0
- package/dist/{validate-CcNRWH6I.d.mts → validate-CaLH1Ia2.d.mts} +5 -52
- package/dist/validate-CaLH1Ia2.d.mts.map +1 -0
- package/dist/{validate-DuZDIxfy.mjs → validate-CqsNItbt.mjs} +2 -2
- package/dist/{validate-DuZDIxfy.mjs.map → validate-CqsNItbt.mjs.map} +1 -1
- package/dist/version-Uaf2ynPX.mjs +7 -0
- package/dist/{version-DlTDRdpv.mjs.map → version-Uaf2ynPX.mjs.map} +1 -1
- package/package.json +10 -5
- package/src/after.ts +62 -0
- package/src/api/handlers/oauth-authorization.ts +2 -32
- package/src/api/handlers/oauth-clients.ts +40 -4
- package/src/api/handlers/taxonomies.ts +13 -0
- package/src/api/oauth/redirect-uri.ts +34 -0
- package/src/api/openapi/document.ts +126 -118
- package/src/api/schemas/auth.ts +7 -0
- package/src/api/schemas/media.ts +26 -15
- package/src/api/schemas/schema.ts +1 -0
- package/src/astro/integration/font-provider.ts +176 -0
- package/src/astro/integration/index.ts +42 -0
- package/src/astro/integration/routes.ts +17 -1
- package/src/astro/integration/runtime.ts +63 -0
- package/src/astro/integration/virtual-modules.ts +41 -39
- package/src/astro/integration/vite-config.ts +16 -5
- package/src/astro/middleware/auth.ts +39 -6
- package/src/astro/middleware/request-context.ts +15 -3
- package/src/astro/middleware.ts +340 -263
- package/src/astro/routes/admin.astro +10 -5
- package/src/astro/routes/api/auth/invite/register-options.ts +78 -0
- package/src/astro/routes/api/auth/passkey/verify.ts +5 -1
- package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +5 -0
- package/src/astro/routes/api/import/wordpress/execute.ts +1 -1
- package/src/astro/routes/api/import/wordpress-plugin/execute.ts +1 -1
- package/src/astro/routes/api/media/upload-url.ts +10 -2
- package/src/astro/routes/api/media.ts +10 -7
- package/src/astro/routes/api/oauth/register.ts +178 -0
- package/src/astro/routes/api/oauth/token.ts +15 -0
- package/src/astro/routes/api/openapi.json.ts +15 -5
- package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +2 -0
- package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +1 -0
- package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +1 -0
- package/src/astro/routes/api/search/index.ts +5 -0
- package/src/astro/routes/api/search/suggest.ts +3 -0
- package/src/astro/routes/api/taxonomies/index.ts +1 -0
- package/src/astro/routes/api/well-known/oauth-authorization-server.ts +6 -4
- package/src/bylines/index.ts +22 -45
- package/src/components/EmDashHead.astro +23 -7
- package/src/components/Table.astro +73 -41
- package/src/components/index.ts +2 -12
- package/src/components/marks.ts +20 -0
- package/src/database/connection.ts +23 -1
- package/src/database/instrumentation.ts +98 -0
- package/src/db/adapters.ts +15 -0
- package/src/emdash-runtime.ts +309 -91
- package/src/index.ts +6 -0
- package/src/loader.ts +19 -24
- package/src/menus/index.ts +6 -3
- package/src/page/index.ts +1 -1
- package/src/page/seo-contributions.ts +36 -0
- package/src/plugins/context.ts +1 -0
- package/src/plugins/email-console.ts +9 -2
- package/src/plugins/types.ts +8 -0
- package/src/query.ts +104 -7
- package/src/request-cache.ts +106 -0
- package/src/request-context.ts +19 -0
- package/src/schema/query.ts +5 -2
- package/src/schema/registry.ts +243 -166
- package/src/schema/types.ts +13 -2
- package/src/schema/zod-generator.ts +4 -0
- package/src/search/fts-manager.ts +19 -5
- package/src/search/query.ts +4 -3
- package/src/seed/apply.ts +15 -1
- package/src/settings/index.ts +24 -5
- package/src/taxonomies/index.ts +324 -124
- package/src/utils/db-errors.ts +46 -0
- package/src/virtual-modules.d.ts +31 -10
- package/src/widgets/index.ts +54 -25
- package/dist/adapters-C2BzVy0p.d.mts.map +0 -1
- package/dist/apply-Cma_PiF6.mjs.map +0 -1
- package/dist/byline-WuOq9MFJ.mjs.map +0 -1
- package/dist/bylines-C_Wsnz4L.mjs.map +0 -1
- package/dist/connection-B4zVnQIa.mjs.map +0 -1
- package/dist/index-CRg3PWfZ.d.mts.map +0 -1
- package/dist/loader-BYzwzORf.mjs.map +0 -1
- package/dist/query-B6Vu0d2i.mjs.map +0 -1
- package/dist/registry-BgnP3ysR.mjs.map +0 -1
- package/dist/search-B5p9D36n.mjs.map +0 -1
- package/dist/types-BYWYxLcp.d.mts.map +0 -1
- package/dist/types-gLYVCXCQ.d.mts.map +0 -1
- package/dist/types-xxCWI3j0.mjs.map +0 -1
- package/dist/validate-CcNRWH6I.d.mts.map +0 -1
- package/dist/version-DlTDRdpv.mjs +0 -7
|
@@ -9,20 +9,23 @@ 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
|
|
|
15
|
-
import { resolveLocale, loadMessages } from "@emdash-cms/admin/locales";
|
|
16
|
+
import { resolveLocale, loadMessages, getLocaleDir } from "@emdash-cms/admin/locales";
|
|
16
17
|
|
|
17
18
|
const resolvedLocale = resolveLocale(Astro.request);
|
|
19
|
+
const resolvedDir = getLocaleDir(resolvedLocale);
|
|
18
20
|
const messages = await loadMessages(resolvedLocale);
|
|
19
21
|
---
|
|
20
22
|
|
|
21
23
|
<!doctype html>
|
|
22
|
-
<html lang={resolvedLocale}>
|
|
24
|
+
<html lang={resolvedLocale} dir={resolvedDir}>
|
|
23
25
|
<head>
|
|
24
26
|
<meta charset="UTF-8" />
|
|
25
27
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
28
|
+
<Font cssVariable="--font-emdash" />
|
|
26
29
|
<link
|
|
27
30
|
rel="icon"
|
|
28
31
|
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>"
|
|
@@ -62,10 +65,12 @@ const messages = await loadMessages(resolvedLocale);
|
|
|
62
65
|
}
|
|
63
66
|
#emdash-boot-loader p {
|
|
64
67
|
margin-top: 1rem;
|
|
65
|
-
font-family:
|
|
68
|
+
font-family: var(
|
|
69
|
+
--font-emdash,
|
|
70
|
+
ui-sans-serif,
|
|
66
71
|
system-ui,
|
|
67
|
-
-
|
|
68
|
-
|
|
72
|
+
sans-serif
|
|
73
|
+
);
|
|
69
74
|
font-size: 0.875rem;
|
|
70
75
|
color: light-dark(hsl(215.4 16.3% 46.9%), hsl(215 20.2% 65.1%));
|
|
71
76
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_emdash/api/auth/invite/register-options
|
|
3
|
+
*
|
|
4
|
+
* Generate WebAuthn registration options for an invited user.
|
|
5
|
+
* Validates the invite token and creates a temporary user identity
|
|
6
|
+
* for the passkey registration flow.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { APIRoute } from "astro";
|
|
10
|
+
|
|
11
|
+
export const prerender = false;
|
|
12
|
+
|
|
13
|
+
import { validateInvite, InviteError } from "@emdash-cms/auth";
|
|
14
|
+
import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
|
|
15
|
+
import { generateRegistrationOptions } from "@emdash-cms/auth/passkey";
|
|
16
|
+
import { ulid } from "ulidx";
|
|
17
|
+
|
|
18
|
+
import { apiError, apiSuccess, handleError } from "#api/error.js";
|
|
19
|
+
import { isParseError, parseBody } from "#api/parse.js";
|
|
20
|
+
import { inviteRegisterOptionsBody } from "#api/schemas.js";
|
|
21
|
+
import { createChallengeStore } from "#auth/challenge-store.js";
|
|
22
|
+
import { getPasskeyConfig } from "#auth/passkey-config.js";
|
|
23
|
+
import { OptionsRepository } from "#db/repositories/options.js";
|
|
24
|
+
|
|
25
|
+
export const POST: APIRoute = async ({ request, locals }) => {
|
|
26
|
+
const { emdash } = locals;
|
|
27
|
+
|
|
28
|
+
if (!emdash?.db) {
|
|
29
|
+
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const body = await parseBody(request, inviteRegisterOptionsBody);
|
|
34
|
+
if (isParseError(body)) return body;
|
|
35
|
+
|
|
36
|
+
// Validate the invite token to get the email
|
|
37
|
+
const adapter = createKyselyAdapter(emdash.db);
|
|
38
|
+
const invite = await validateInvite(adapter, body.token);
|
|
39
|
+
|
|
40
|
+
// Get passkey config
|
|
41
|
+
const url = new URL(request.url);
|
|
42
|
+
const options = new OptionsRepository(emdash.db);
|
|
43
|
+
const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
|
|
44
|
+
const passkeyConfig = getPasskeyConfig(url, siteName);
|
|
45
|
+
|
|
46
|
+
// Generate registration options with a temporary user identity
|
|
47
|
+
const challengeStore = createChallengeStore(emdash.db);
|
|
48
|
+
const tempUser = {
|
|
49
|
+
id: ulid(),
|
|
50
|
+
email: invite.email,
|
|
51
|
+
name: body.name || null,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const registrationOptions = await generateRegistrationOptions(
|
|
55
|
+
passkeyConfig,
|
|
56
|
+
tempUser,
|
|
57
|
+
[],
|
|
58
|
+
challengeStore,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return apiSuccess({ options: registrationOptions });
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error instanceof InviteError) {
|
|
64
|
+
const statusMap: Record<string, number> = {
|
|
65
|
+
invalid_token: 404,
|
|
66
|
+
token_expired: 410,
|
|
67
|
+
user_exists: 409,
|
|
68
|
+
};
|
|
69
|
+
return apiError(error.code.toUpperCase(), error.message, statusMap[error.code] ?? 400);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return handleError(
|
|
73
|
+
error,
|
|
74
|
+
"Failed to generate registration options",
|
|
75
|
+
"INVITE_REGISTER_OPTIONS_ERROR",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
@@ -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
|
};
|
|
@@ -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
|
|
|
@@ -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
|
}
|
|
@@ -332,7 +332,7 @@ async function importContent(
|
|
|
332
332
|
console.error(`Import error for "${item.title || "Untitled"}":`, error);
|
|
333
333
|
result.errors.push({
|
|
334
334
|
title: item.title || "Untitled",
|
|
335
|
-
error: "Failed to import item",
|
|
335
|
+
error: error instanceof Error && error.message ? error.message : "Failed to import item",
|
|
336
336
|
});
|
|
337
337
|
}
|
|
338
338
|
}
|
|
@@ -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
|
|
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) >
|
|
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 >
|
|
116
|
+
if (file.size > maxUploadSize) {
|
|
114
117
|
return apiError(
|
|
115
118
|
"PAYLOAD_TOO_LARGE",
|
|
116
|
-
`File exceeds maximum size of ${
|
|
119
|
+
`File exceeds maximum size of ${formatFileSize(maxUploadSize)}`,
|
|
117
120
|
413,
|
|
118
121
|
);
|
|
119
122
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_emdash/api/oauth/register
|
|
3
|
+
*
|
|
4
|
+
* RFC 7591 Dynamic Client Registration. Public, unauthenticated.
|
|
5
|
+
* MCP clients (e.g. Claude Code) call this to register themselves
|
|
6
|
+
* before starting the OAuth authorization flow.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { APIRoute } from "astro";
|
|
10
|
+
|
|
11
|
+
import { apiError, handleError } from "#api/error.js";
|
|
12
|
+
import { handleOAuthClientCreate } from "#api/handlers/oauth-clients.js";
|
|
13
|
+
|
|
14
|
+
export const prerender = false;
|
|
15
|
+
|
|
16
|
+
const OAUTH_REGISTRATION_HEADERS: HeadersInit = {
|
|
17
|
+
"Cache-Control": "no-store",
|
|
18
|
+
Pragma: "no-cache",
|
|
19
|
+
// RFC 7591 dynamic client registration is called cross-origin by MCP clients,
|
|
20
|
+
// CLIs, and native apps. The endpoint is anonymous and carries no ambient
|
|
21
|
+
// credentials, so CORS `*` is safe.
|
|
22
|
+
"Access-Control-Allow-Origin": "*",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const OAUTH_PREFLIGHT_HEADERS: HeadersInit = {
|
|
26
|
+
"Access-Control-Allow-Origin": "*",
|
|
27
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
28
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
29
|
+
"Access-Control-Max-Age": "86400",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const SUPPORTED_GRANT_TYPES = new Set([
|
|
33
|
+
"authorization_code",
|
|
34
|
+
"refresh_token",
|
|
35
|
+
"urn:ietf:params:oauth:grant-type:device_code",
|
|
36
|
+
]);
|
|
37
|
+
const SUPPORTED_RESPONSE_TYPES = new Set(["code"]);
|
|
38
|
+
|
|
39
|
+
function registrationError(description: string, status = 400): Response {
|
|
40
|
+
return Response.json(
|
|
41
|
+
{
|
|
42
|
+
error: "invalid_client_metadata",
|
|
43
|
+
error_description: description,
|
|
44
|
+
},
|
|
45
|
+
{ status, headers: OAUTH_REGISTRATION_HEADERS },
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
50
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isStringArray(value: unknown): value is string[] {
|
|
54
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseScope(value: unknown): string[] | Response | undefined {
|
|
58
|
+
if (value === undefined) return undefined;
|
|
59
|
+
if (typeof value === "string") {
|
|
60
|
+
const scopes = value.split(" ").filter(Boolean);
|
|
61
|
+
return scopes.length > 0 ? scopes : undefined;
|
|
62
|
+
}
|
|
63
|
+
if (isStringArray(value)) {
|
|
64
|
+
const scopes = value.filter(Boolean);
|
|
65
|
+
return scopes.length > 0 ? scopes : undefined;
|
|
66
|
+
}
|
|
67
|
+
return registrationError("scope must be a string or array of strings");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseSupportedStringArray(
|
|
71
|
+
value: unknown,
|
|
72
|
+
field: string,
|
|
73
|
+
supported: ReadonlySet<string>,
|
|
74
|
+
): string[] | Response | undefined {
|
|
75
|
+
if (value === undefined) return undefined;
|
|
76
|
+
if (!isStringArray(value)) {
|
|
77
|
+
return registrationError(`${field} must be an array of strings`);
|
|
78
|
+
}
|
|
79
|
+
const invalidValue = value.find((item) => !supported.has(item));
|
|
80
|
+
if (invalidValue) {
|
|
81
|
+
return registrationError(`${field} contains unsupported value: ${invalidValue}`);
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const OPTIONS: APIRoute = () => {
|
|
87
|
+
return new Response(null, { status: 204, headers: OAUTH_PREFLIGHT_HEADERS });
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const POST: APIRoute = async ({ request, locals }) => {
|
|
91
|
+
const { emdash } = locals;
|
|
92
|
+
|
|
93
|
+
if (!emdash?.db) {
|
|
94
|
+
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
let body: unknown;
|
|
99
|
+
try {
|
|
100
|
+
body = await request.json();
|
|
101
|
+
} catch {
|
|
102
|
+
return registrationError("Request body must be valid JSON");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!isRecord(body)) {
|
|
106
|
+
return registrationError("Request body must be a JSON object");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// redirect_uris is the only required field per RFC 7591 §2
|
|
110
|
+
if (!isStringArray(body.redirect_uris) || body.redirect_uris.length === 0) {
|
|
111
|
+
return registrationError("redirect_uris must be a non-empty array of strings");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (
|
|
115
|
+
body.token_endpoint_auth_method !== undefined &&
|
|
116
|
+
body.token_endpoint_auth_method !== "none"
|
|
117
|
+
) {
|
|
118
|
+
return registrationError("Only token_endpoint_auth_method=none is supported");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const grantTypes = parseSupportedStringArray(
|
|
122
|
+
body.grant_types,
|
|
123
|
+
"grant_types",
|
|
124
|
+
SUPPORTED_GRANT_TYPES,
|
|
125
|
+
);
|
|
126
|
+
if (grantTypes instanceof Response) {
|
|
127
|
+
return grantTypes;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const responseTypes = parseSupportedStringArray(
|
|
131
|
+
body.response_types,
|
|
132
|
+
"response_types",
|
|
133
|
+
SUPPORTED_RESPONSE_TYPES,
|
|
134
|
+
);
|
|
135
|
+
if (responseTypes instanceof Response) {
|
|
136
|
+
return responseTypes;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const scopes = parseScope(body.scope);
|
|
140
|
+
if (scopes instanceof Response) {
|
|
141
|
+
return scopes;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const clientId = crypto.randomUUID();
|
|
145
|
+
const clientName =
|
|
146
|
+
typeof body.client_name === "string" && body.client_name
|
|
147
|
+
? body.client_name
|
|
148
|
+
: `dynamic-${clientId.slice(0, 8)}`;
|
|
149
|
+
|
|
150
|
+
const result = await handleOAuthClientCreate(emdash.db, {
|
|
151
|
+
id: clientId,
|
|
152
|
+
name: clientName,
|
|
153
|
+
redirectUris: body.redirect_uris,
|
|
154
|
+
scopes,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!result.success) {
|
|
158
|
+
return registrationError(result.error.message);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// RFC 7591 §3.2.1 response
|
|
162
|
+
return Response.json(
|
|
163
|
+
{
|
|
164
|
+
client_id: result.data.id,
|
|
165
|
+
client_id_issued_at: Math.floor(new Date(result.data.createdAt).getTime() / 1000),
|
|
166
|
+
redirect_uris: result.data.redirectUris,
|
|
167
|
+
client_name: result.data.name,
|
|
168
|
+
grant_types: grantTypes ?? ["authorization_code", "refresh_token"],
|
|
169
|
+
response_types: responseTypes ?? ["code"],
|
|
170
|
+
token_endpoint_auth_method: "none",
|
|
171
|
+
scope: result.data.scopes ? result.data.scopes.join(" ") : undefined,
|
|
172
|
+
},
|
|
173
|
+
{ status: 201, headers: OAUTH_REGISTRATION_HEADERS },
|
|
174
|
+
);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
return handleError(error, "Failed to register OAuth client", "CLIENT_REGISTER_ERROR");
|
|
177
|
+
}
|
|
178
|
+
};
|
|
@@ -87,6 +87,10 @@ const refreshSchema = z.object({
|
|
|
87
87
|
// Handler
|
|
88
88
|
// ---------------------------------------------------------------------------
|
|
89
89
|
|
|
90
|
+
export const OPTIONS: APIRoute = () => {
|
|
91
|
+
return new Response(null, { status: 204, headers: OAUTH_PREFLIGHT_HEADERS });
|
|
92
|
+
};
|
|
93
|
+
|
|
90
94
|
export const POST: APIRoute = async ({ request, locals }) => {
|
|
91
95
|
const { emdash } = locals;
|
|
92
96
|
|
|
@@ -166,6 +170,17 @@ const OAUTH_TOKEN_HEADERS: HeadersInit = {
|
|
|
166
170
|
"Content-Type": "application/json",
|
|
167
171
|
"Cache-Control": "no-store",
|
|
168
172
|
Pragma: "no-cache",
|
|
173
|
+
// OAuth 2.1 token endpoint is called cross-origin by external clients. Caller
|
|
174
|
+
// must present PKCE code_verifier / device_code / refresh_token on each request,
|
|
175
|
+
// so there is no ambient credential for CSRF to exploit.
|
|
176
|
+
"Access-Control-Allow-Origin": "*",
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const OAUTH_PREFLIGHT_HEADERS: HeadersInit = {
|
|
180
|
+
"Access-Control-Allow-Origin": "*",
|
|
181
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
182
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
183
|
+
"Access-Control-Max-Age": "86400",
|
|
169
184
|
};
|
|
170
185
|
|
|
171
186
|
function oauthSuccess(data: unknown): Response {
|
|
@@ -15,13 +15,23 @@ export const prerender = false;
|
|
|
15
15
|
|
|
16
16
|
let cachedSpec: string | null = null;
|
|
17
17
|
|
|
18
|
-
export const GET: APIRoute = async () => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
export const GET: APIRoute = async ({ locals }) => {
|
|
19
|
+
const { emdash } = locals;
|
|
20
|
+
if (!cachedSpec && emdash) {
|
|
21
|
+
try {
|
|
22
|
+
const doc = generateOpenApiDocument({ maxUploadSize: emdash.config.maxUploadSize });
|
|
23
|
+
cachedSpec = JSON.stringify(doc);
|
|
24
|
+
} catch {
|
|
25
|
+
return new Response(
|
|
26
|
+
JSON.stringify({ error: "Failed to generate OpenAPI document: invalid configuration" }),
|
|
27
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
28
|
+
);
|
|
29
|
+
}
|
|
22
30
|
}
|
|
23
31
|
|
|
24
|
-
|
|
32
|
+
const spec = cachedSpec ?? JSON.stringify(generateOpenApiDocument());
|
|
33
|
+
|
|
34
|
+
return new Response(spec, {
|
|
25
35
|
status: 200,
|
|
26
36
|
headers: {
|
|
27
37
|
"Content-Type": "application/json",
|
|
@@ -57,6 +57,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
|
|
|
57
57
|
fieldSlug,
|
|
58
58
|
body as UpdateFieldInput,
|
|
59
59
|
);
|
|
60
|
+
if (result.success) emdash!.invalidateManifest();
|
|
60
61
|
return unwrapResult(result);
|
|
61
62
|
};
|
|
62
63
|
|
|
@@ -72,5 +73,6 @@ export const DELETE: APIRoute = async ({ params, locals }) => {
|
|
|
72
73
|
if (denied) return denied;
|
|
73
74
|
|
|
74
75
|
const result = await handleSchemaFieldDelete(emdash!.db, collectionSlug, fieldSlug);
|
|
76
|
+
if (result.success) emdash!.invalidateManifest();
|
|
75
77
|
return unwrapResult(result);
|
|
76
78
|
};
|
|
@@ -28,5 +28,6 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
|
|
|
28
28
|
if (isParseError(body)) return body;
|
|
29
29
|
|
|
30
30
|
const result = await handleSchemaFieldReorder(emdash!.db, collectionSlug, body.fieldSlugs);
|
|
31
|
+
if (result.success) emdash!.invalidateManifest();
|
|
31
32
|
return unwrapResult(result);
|
|
32
33
|
};
|
|
@@ -37,6 +37,11 @@ export const GET: APIRoute = async ({ url, locals }) => {
|
|
|
37
37
|
: undefined;
|
|
38
38
|
|
|
39
39
|
try {
|
|
40
|
+
// Verify FTS indexes are healthy on first use. At most once per worker
|
|
41
|
+
// lifetime; no-op after that. Moved off the cold-start hot path to
|
|
42
|
+
// keep anonymous public reads fast.
|
|
43
|
+
await emdash.ensureSearchHealthy?.();
|
|
44
|
+
|
|
40
45
|
const result = await searchWithDb(emdash.db, query.q, {
|
|
41
46
|
collections,
|
|
42
47
|
status: query.status,
|
|
@@ -36,6 +36,9 @@ export const GET: APIRoute = async ({ url, locals }) => {
|
|
|
36
36
|
: undefined;
|
|
37
37
|
|
|
38
38
|
try {
|
|
39
|
+
// Verify FTS indexes are healthy on first use. See search/index.ts.
|
|
40
|
+
await emdash.ensureSearchHealthy?.();
|
|
41
|
+
|
|
39
42
|
const suggestions = await getSuggestions(emdash.db, query.q, {
|
|
40
43
|
collections,
|
|
41
44
|
locale: query.locale,
|
|
@@ -52,6 +52,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
52
52
|
if (isParseError(body)) return body;
|
|
53
53
|
|
|
54
54
|
const result = await handleTaxonomyCreate(emdash.db, body);
|
|
55
|
+
if (result.success) emdash.invalidateManifest();
|
|
55
56
|
return unwrapResult(result, 201);
|
|
56
57
|
} catch (error) {
|
|
57
58
|
return handleError(error, "Failed to create taxonomy", "TAXONOMY_CREATE_ERROR");
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GET
|
|
2
|
+
* GET /.well-known/oauth-authorization-server/_emdash
|
|
3
3
|
*
|
|
4
|
-
* RFC 8414 Authorization Server Metadata.
|
|
5
|
-
*
|
|
4
|
+
* RFC 8414 Authorization Server Metadata. The path follows the RFC 8414
|
|
5
|
+
* convention: the issuer's pathname (/_emdash) is appended after
|
|
6
|
+
* /.well-known/oauth-authorization-server, so MCP clients can discover
|
|
7
|
+
* it automatically from the authorization_servers URL.
|
|
6
8
|
*
|
|
7
9
|
* Public, unauthenticated.
|
|
8
10
|
*/
|
|
@@ -31,8 +33,8 @@ export const GET: APIRoute = async ({ url, locals }) => {
|
|
|
31
33
|
"urn:ietf:params:oauth:grant-type:device_code",
|
|
32
34
|
],
|
|
33
35
|
code_challenge_methods_supported: ["S256"],
|
|
36
|
+
registration_endpoint: `${origin}/_emdash/api/oauth/register`,
|
|
34
37
|
token_endpoint_auth_methods_supported: ["none"],
|
|
35
|
-
client_id_metadata_document_supported: true,
|
|
36
38
|
device_authorization_endpoint: `${origin}/_emdash/api/oauth/device/code`,
|
|
37
39
|
},
|
|
38
40
|
{
|