@karibsen/eponyme 0.2.0 → 0.3.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/README.md +38 -5
- package/dist/eponyme.mjs +2 -1
- package/dist/module.d.mts +6 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +10 -1
- package/dist/runtime/composables/useEponymeForm.d.ts +4 -0
- package/dist/runtime/composables/useEponymeForm.js +8 -1
- package/dist/runtime/server/api/eponyme-forms/[path].post.js +15 -0
- package/dist/runtime/server/services/eponyme-form-store.d.ts +0 -1
- package/dist/runtime/server/services/eponyme-form-store.js +2 -1
- package/dist/runtime/server/services/eponyme-service.js +8 -1
- package/dist/runtime/server/services/eponyme-store.d.ts +43 -3
- package/dist/runtime/server/services/eponyme-store.js +114 -40
- package/dist/runtime/server/utils/eponyme-captcha.d.ts +6 -0
- package/dist/runtime/server/utils/eponyme-captcha.js +14 -0
- package/dist/runtime/types/captcha.d.ts +16 -0
- package/dist/runtime/types/captcha.js +0 -0
- package/dist/runtime/types/eponyme-config.d.ts +7 -0
- package/dist/runtime/types/eponyme.d.ts +3 -0
- package/dist/runtime/types/index.d.ts +1 -0
- package/dist/runtime/types/index.js +1 -0
- package/dist/runtime/utils/no-captcha.d.ts +7 -0
- package/dist/runtime/utils/no-captcha.js +5 -0
- package/dist/runtime/utils/variables.d.ts +8 -1
- package/dist/runtime/utils/variables.js +17 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -668,10 +668,43 @@ Two protections apply to the public submission route:
|
|
|
668
668
|
- a **body size limit**, 64 KB by default, configurable with `maxBodyBytes`. An oversized
|
|
669
669
|
body is refused with `413` before it is parsed.
|
|
670
670
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
671
|
+
### Captcha
|
|
672
|
+
|
|
673
|
+
Set `captcha: true` on the form, then install an adapter — Eponyme defines the contract
|
|
674
|
+
but ships no provider:
|
|
675
|
+
|
|
676
|
+
```bash
|
|
677
|
+
pnpm add @karibsen/eponyme-captcha
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
```ts
|
|
681
|
+
export default defineNuxtConfig({
|
|
682
|
+
modules: ['@karibsen/eponyme', '@karibsen/eponyme-captcha'],
|
|
683
|
+
eponymeCaptcha: { siteKey: '<your-turnstile-site-key>' },
|
|
684
|
+
})
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
Render the widget and bind its token; `useEponymeForm` sends it alongside the fields and
|
|
688
|
+
the server verifies it before validating anything:
|
|
689
|
+
|
|
690
|
+
```vue
|
|
691
|
+
<script setup lang="ts">
|
|
692
|
+
const { captchaToken, requiresCaptcha, submit } = useEponymeForm('contact')
|
|
693
|
+
</script>
|
|
694
|
+
|
|
695
|
+
<template>
|
|
696
|
+
<NuxtTurnstile v-if="requiresCaptcha" v-model="captchaToken" />
|
|
697
|
+
</template>
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
**It fails closed.** Without an adapter, a form declaring `captcha: true` answers `500`
|
|
701
|
+
rather than accepting submissions unprotected. A rejected token answers `422` with a
|
|
702
|
+
`_form` error; the provider's reason stays in the server log, since telling a bot which
|
|
703
|
+
check it failed only helps it.
|
|
704
|
+
|
|
705
|
+
Writing an adapter for another provider means supplying a `name` and an
|
|
706
|
+
`async verify(token, { ip })` returning `{ success, reason? }`, then pointing the
|
|
707
|
+
`#eponyme/captcha` alias at it.
|
|
675
708
|
|
|
676
709
|
Rate limiting and file uploads are not implemented yet.
|
|
677
710
|
|
|
@@ -730,7 +763,7 @@ a trash move leaves the entry itself untouched.
|
|
|
730
763
|
|
|
731
764
|
## Current status
|
|
732
765
|
|
|
733
|
-
Eponyme is at version `0.
|
|
766
|
+
Eponyme is at version `0.3.0`. It is ready for controlled projects and production pilots. A few workflows still need hardening before a broad public release:
|
|
734
767
|
|
|
735
768
|
- Client revision tokens for long-running concurrent edits: the current `updatedAt` check protects overlapping writes within one request, not two editors who loaded the same older revision. A deletion is not guarded by a revision either
|
|
736
769
|
- Retention controls for the trash: entries stay there until someone empties it
|
package/dist/eponyme.mjs
CHANGED
|
@@ -31,7 +31,8 @@ function form(options) {
|
|
|
31
31
|
// Storing a submission has to be an explicit decision, so `custom` is the default.
|
|
32
32
|
submission: { mode: options.submission?.mode ?? "custom" },
|
|
33
33
|
honeypot,
|
|
34
|
-
maxBodyBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
34
|
+
maxBodyBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
|
|
35
|
+
captcha: options.captcha ?? false
|
|
35
36
|
};
|
|
36
37
|
}
|
|
37
38
|
|
package/dist/module.d.mts
CHANGED
|
@@ -20,6 +20,12 @@ interface ModuleOptions {
|
|
|
20
20
|
/** Fixed lifetime of an authenticated session. */
|
|
21
21
|
sessionDurationDays?: number;
|
|
22
22
|
};
|
|
23
|
+
/**
|
|
24
|
+
* How long a server instance may reuse content it has already read, in seconds.
|
|
25
|
+
* A save clears what it changed on its own instance, so this only bounds how long
|
|
26
|
+
* another instance can still serve the previous content. `0` disables the cache.
|
|
27
|
+
*/
|
|
28
|
+
cacheSeconds?: number;
|
|
23
29
|
}
|
|
24
30
|
declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
|
|
25
31
|
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -15,7 +15,8 @@ const module$1 = defineNuxtModule({
|
|
|
15
15
|
colorPresets: [],
|
|
16
16
|
auth: {
|
|
17
17
|
sessionDurationDays: 7
|
|
18
|
-
}
|
|
18
|
+
},
|
|
19
|
+
cacheSeconds: 5
|
|
19
20
|
},
|
|
20
21
|
moduleDependencies: {
|
|
21
22
|
"@nuxt/icon": {
|
|
@@ -70,6 +71,9 @@ const module$1 = defineNuxtModule({
|
|
|
70
71
|
nuxt.options.runtimeConfig.eponymeAuth = {
|
|
71
72
|
sessionDurationDays: options.auth?.sessionDurationDays ?? 7
|
|
72
73
|
};
|
|
74
|
+
nuxt.options.runtimeConfig.eponymeContent = {
|
|
75
|
+
cacheSeconds: options.cacheSeconds ?? 5
|
|
76
|
+
};
|
|
73
77
|
const preparing = Boolean(nuxt.options._prepare);
|
|
74
78
|
if (!options.prismaClient) {
|
|
75
79
|
if (!preparing) throw new Error(`${pc.red("[Eponyme]")} eponyme.prismaClient is required.`);
|
|
@@ -130,6 +134,7 @@ Note that '~/' points at ${nuxt.options.srcDir}, so a server module needs '~~/'.
|
|
|
130
134
|
nuxt.options.alias["#eponyme/prisma"] = prismaPath;
|
|
131
135
|
const variablesPath = await findPath(resolve(nuxt.options.rootDir, "eponyme.variables.ts"));
|
|
132
136
|
nuxt.options.alias["#eponyme/variables"] = variablesPath ?? resolver.resolve("./runtime/utils/empty-variables");
|
|
137
|
+
nuxt.options.alias["#eponyme/captcha"] ??= resolver.resolve("./runtime/utils/no-captcha");
|
|
133
138
|
addTypeTemplate({
|
|
134
139
|
filename: "types/eponyme-config.d.ts",
|
|
135
140
|
getContents: () => `declare module '#eponyme/config' {
|
|
@@ -140,6 +145,10 @@ declare module '#eponyme/variables' {
|
|
|
140
145
|
const variables: typeof import(${JSON.stringify(nuxt.options.alias["#eponyme/variables"])})['default']
|
|
141
146
|
export default variables
|
|
142
147
|
}
|
|
148
|
+
declare module '#eponyme/captcha' {
|
|
149
|
+
const verifier: import('${resolver.resolve("./runtime/types/captcha")}').EponymeCaptchaVerifier
|
|
150
|
+
export default verifier
|
|
151
|
+
}
|
|
143
152
|
`
|
|
144
153
|
});
|
|
145
154
|
addServerHandler({ route: "/api/eponyme/**", handler: resolver.resolve("./runtime/server/api/eponyme/[name].get") });
|
|
@@ -24,6 +24,10 @@ export interface UseEponymeFormResult<Data extends Record<string, unknown>> {
|
|
|
24
24
|
submitted: Ref<boolean>;
|
|
25
25
|
/** Name of the trap field to render hidden, or `false` when disabled. */
|
|
26
26
|
honeypot: string | false;
|
|
27
|
+
/** True when the form declares `captcha: true`; render a widget and fill `captchaToken`. */
|
|
28
|
+
requiresCaptcha: boolean;
|
|
29
|
+
/** Bind this to the captcha widget, for example `<NuxtTurnstile v-model="captchaToken" />`. */
|
|
30
|
+
captchaToken: Ref<string>;
|
|
27
31
|
submit: () => Promise<boolean>;
|
|
28
32
|
reset: () => void;
|
|
29
33
|
}
|
|
@@ -10,12 +10,14 @@ function resolveForm(name) {
|
|
|
10
10
|
if (!definition) throw new Error(`[Eponyme] Unknown form "${name}". Declare it with form() in eponyme.config.ts.`);
|
|
11
11
|
return definition;
|
|
12
12
|
}
|
|
13
|
+
const CAPTCHA_TOKEN_KEY = "_eponyme_captcha";
|
|
13
14
|
export function useEponymeForm(name, options = {}) {
|
|
14
15
|
const definition = resolveForm(name);
|
|
15
16
|
const requestFetch = useRequestFetch();
|
|
16
17
|
const serverErrors = ref({});
|
|
17
18
|
const pending = ref(false);
|
|
18
19
|
const submitted = ref(false);
|
|
20
|
+
const captchaToken = ref("");
|
|
19
21
|
const values = ref(createDefaultEponymeData(definition.fields));
|
|
20
22
|
const validation = useEponymeValidation(definition.fields, values, ref({}), { serverErrors });
|
|
21
23
|
const fields = computed(() => Object.entries(definition.fields).map(([fieldName, fieldDefinition]) => ({
|
|
@@ -33,6 +35,7 @@ export function useEponymeForm(name, options = {}) {
|
|
|
33
35
|
values.value = createDefaultEponymeData(definition.fields);
|
|
34
36
|
serverErrors.value = {};
|
|
35
37
|
submitted.value = false;
|
|
38
|
+
captchaToken.value = "";
|
|
36
39
|
validation.reset();
|
|
37
40
|
}
|
|
38
41
|
async function submit() {
|
|
@@ -49,7 +52,9 @@ export function useEponymeForm(name, options = {}) {
|
|
|
49
52
|
console.warn(`[Eponyme] Form "${name}" is managed, so its onSubmit handler is ignored.`);
|
|
50
53
|
await requestFetch(`/api/eponyme-forms/${name}`, {
|
|
51
54
|
method: "POST",
|
|
52
|
-
|
|
55
|
+
// The token travels beside the fields, never as one of them: it is
|
|
56
|
+
// transport, and the schema only knows about declared fields.
|
|
57
|
+
body: definition.captcha ? { ...values.value, [CAPTCHA_TOKEN_KEY]: captchaToken.value } : values.value
|
|
53
58
|
});
|
|
54
59
|
}
|
|
55
60
|
submitted.value = true;
|
|
@@ -72,6 +77,8 @@ export function useEponymeForm(name, options = {}) {
|
|
|
72
77
|
pending,
|
|
73
78
|
submitted,
|
|
74
79
|
honeypot: definition.honeypot,
|
|
80
|
+
requiresCaptcha: definition.captcha,
|
|
81
|
+
captchaToken,
|
|
75
82
|
submit,
|
|
76
83
|
reset
|
|
77
84
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createError, defineEventHandler, getRequestHeader, readRawBody, setResponseHeader, setResponseStatus } from "h3";
|
|
2
2
|
import { useEponymeFormService } from "../../services/eponyme-form-service.js";
|
|
3
3
|
import { readEponymeFormRoute } from "../../utils/form-route.js";
|
|
4
|
+
import { CAPTCHA_TOKEN_KEY, isEponymeCaptchaConfigured, verifyEponymeCaptcha } from "../../utils/eponyme-captcha.js";
|
|
4
5
|
import { callEponymeBlockingHook, callEponymeHook } from "../../utils/eponyme-hooks.js";
|
|
5
6
|
export default defineEventHandler(async (event) => {
|
|
6
7
|
const route = readEponymeFormRoute(event);
|
|
@@ -26,6 +27,20 @@ export default defineEventHandler(async (event) => {
|
|
|
26
27
|
setResponseStatus(event, 201);
|
|
27
28
|
return { submitted: true };
|
|
28
29
|
}
|
|
30
|
+
if (definition.captcha) {
|
|
31
|
+
if (!isEponymeCaptchaConfigured()) {
|
|
32
|
+
throw createError({
|
|
33
|
+
statusCode: 500,
|
|
34
|
+
statusMessage: `Form "${route.name}" requires a captcha but no adapter is installed. Add one, such as @karibsen/eponyme-captcha.`
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const token = payload && typeof payload === "object" ? payload[CAPTCHA_TOKEN_KEY] : void 0;
|
|
38
|
+
const result2 = await verifyEponymeCaptcha(event, token);
|
|
39
|
+
if (!result2.success) {
|
|
40
|
+
setResponseStatus(event, 422);
|
|
41
|
+
return { errors: { _form: ["Captcha verification failed. Please try again."] } };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
29
44
|
const validated = service.validate(route.name, payload);
|
|
30
45
|
if (validated && "errors" in validated) {
|
|
31
46
|
setResponseStatus(event, 422);
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { EponymeConfig, EponymeFormDefinitionBase } from '../../types/index.js';
|
|
2
2
|
import { type ValidationErrors } from '../../utils/validate-eponyme-data.js';
|
|
3
|
-
/** Kept here rather than imported, so the store stays free of H3 imports. */
|
|
4
3
|
type DateValue = Date | string;
|
|
5
4
|
export interface EponymeFormSubmission {
|
|
6
5
|
id: string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { getEponymeForms } from "../../utils/get-eponyme-schemas.js";
|
|
3
3
|
import { validateEponymeData } from "../../utils/validate-eponyme-data.js";
|
|
4
|
+
const CAPTCHA_TOKEN_KEY = "_eponyme_captcha";
|
|
4
5
|
const DEFAULT_PER_PAGE = 25;
|
|
5
6
|
const MAX_PER_PAGE = 100;
|
|
6
7
|
export class EponymeFormService {
|
|
@@ -36,7 +37,7 @@ export class EponymeFormService {
|
|
|
36
37
|
if (!payload || typeof payload !== "object" || Array.isArray(payload))
|
|
37
38
|
return { errors: { _form: ["Body must be an object."] } };
|
|
38
39
|
const input = payload;
|
|
39
|
-
const { [definition.honeypot || ""]: _honeypot, ...rest } = input;
|
|
40
|
+
const { [definition.honeypot || ""]: _honeypot, [CAPTCHA_TOKEN_KEY]: _captcha, ...rest } = input;
|
|
40
41
|
const unknownKeys = Object.keys(rest).filter((key) => !(key in definition.fields));
|
|
41
42
|
if (unknownKeys.length)
|
|
42
43
|
return { errors: Object.fromEntries(unknownKeys.map((key) => [key, ["Unknown field."]])) };
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import eponymeConfig from "#eponyme/config";
|
|
2
2
|
import prisma from "#eponyme/prisma";
|
|
3
|
+
import { useRuntimeConfig } from "nitropack/runtime";
|
|
3
4
|
import { EponymeService } from "./eponyme-store.js";
|
|
4
5
|
export { EponymeService } from "./eponyme-store.js";
|
|
5
|
-
|
|
6
|
+
let eponymeService;
|
|
6
7
|
export function useEponymeService() {
|
|
8
|
+
if (!eponymeService) {
|
|
9
|
+
const contentConfig = useRuntimeConfig().eponymeContent;
|
|
10
|
+
eponymeService = new EponymeService(eponymeConfig, prisma, {
|
|
11
|
+
cacheSeconds: contentConfig?.cacheSeconds ?? 5
|
|
12
|
+
});
|
|
13
|
+
}
|
|
7
14
|
return eponymeService;
|
|
8
15
|
}
|
|
@@ -209,17 +209,51 @@ export declare class EponymeService {
|
|
|
209
209
|
private readonly client;
|
|
210
210
|
private readonly schemas;
|
|
211
211
|
private readonly collections;
|
|
212
|
-
|
|
212
|
+
/** Entries a read has already persisted the normalized shape for, so it only attempts it once. */
|
|
213
|
+
private readonly healedByRead;
|
|
214
|
+
/**
|
|
215
|
+
* Read-path cache of normalized rows, so a page that reads the same entry from a
|
|
216
|
+
* singleton, a listing and a layout pays one round trip instead of one per read.
|
|
217
|
+
* Writers never read from it, so the `updatedAt` they lock on always comes from the
|
|
218
|
+
* database and a stale key can never turn into a spurious conflict.
|
|
219
|
+
*/
|
|
220
|
+
private readonly cache;
|
|
221
|
+
private readonly cacheMs;
|
|
222
|
+
constructor(config: EponymeConfig, client: PrismaEponymeClient, options?: {
|
|
223
|
+
cacheSeconds?: number;
|
|
224
|
+
});
|
|
225
|
+
private cached;
|
|
226
|
+
/**
|
|
227
|
+
* Drops what a write to `name` invalidates: the entry itself, and the listing of the
|
|
228
|
+
* collection that contains it, whose row set just changed.
|
|
229
|
+
*/
|
|
230
|
+
private invalidate;
|
|
213
231
|
getSchema(name: string): EponymeSchema | undefined;
|
|
214
232
|
getCollection(name: string): EponymeCollectionDefinitionBase | undefined;
|
|
215
233
|
private getCollectionEntry;
|
|
216
|
-
/** Reconcile every configured eponyme at application startup. */
|
|
234
|
+
/** Reconcile every configured eponyme at application startup. This is the one place that heals. */
|
|
217
235
|
syncAll(): Promise<void>;
|
|
218
236
|
private reconcile;
|
|
219
237
|
private createState;
|
|
220
238
|
private normalizeState;
|
|
221
|
-
/**
|
|
239
|
+
/**
|
|
240
|
+
* A singleton row is created on first read, but only when it is genuinely missing.
|
|
241
|
+
* An `upsert` would send a write on every read instead, and each one costs a database
|
|
242
|
+
* round trip on the critical path of a public page.
|
|
243
|
+
*/
|
|
244
|
+
private loadSingletonRow;
|
|
245
|
+
/**
|
|
246
|
+
* Reads the stored state along with the `updatedAt` it was read at, used as the optimistic lock token.
|
|
247
|
+
*
|
|
248
|
+
* `heal` persists the normalized state when it differs from what is stored. It has to stay on for
|
|
249
|
+
* writers, because healing moves `updatedAt` and so decides the lock token they are about to compare.
|
|
250
|
+
*
|
|
251
|
+
* `cache` is only ever set for published content. Draft reads serve the preview panel, which has to
|
|
252
|
+
* show a save immediately, and they come from the dashboard rather than from a public page — so
|
|
253
|
+
* there is nothing to gain by caching them and a stale preview to lose.
|
|
254
|
+
*/
|
|
222
255
|
private loadRow;
|
|
256
|
+
private readRow;
|
|
223
257
|
private loadState;
|
|
224
258
|
/**
|
|
225
259
|
* Writes a new state only if the row still carries the `updatedAt` we read.
|
|
@@ -227,6 +261,12 @@ export declare class EponymeService {
|
|
|
227
261
|
* instead of silently discarding their changes.
|
|
228
262
|
*/
|
|
229
263
|
private writeState;
|
|
264
|
+
/**
|
|
265
|
+
* The live rows of a collection, shared by the public listing and the sitemap so both
|
|
266
|
+
* read them once. The trash and the export deliberately query around it: one wants the
|
|
267
|
+
* rows this filter excludes, the other must not see a cached view of the content.
|
|
268
|
+
*/
|
|
269
|
+
private liveCollectionRows;
|
|
230
270
|
get(name: string, version?: EponymeVersionSelector): Promise<Record<string, unknown> | undefined>;
|
|
231
271
|
getResult(name: string, version?: EponymeVersionSelector): Promise<EponymeResult | undefined>;
|
|
232
272
|
/** Content as it was in a given version, for previewing without restoring it. */
|
|
@@ -7,13 +7,44 @@ import { applyPreviewSlug } from "../../utils/preview.js";
|
|
|
7
7
|
import { validateEponymeData, validateEponymePatch } from "../../utils/validate-eponyme-data.js";
|
|
8
8
|
export const COLLECTION_METADATA_KEYS = ["updatedAt", "publishedAt", "title", "slug"];
|
|
9
9
|
export class EponymeService {
|
|
10
|
-
constructor(config, client) {
|
|
10
|
+
constructor(config, client, options = {}) {
|
|
11
11
|
this.client = client;
|
|
12
12
|
this.schemas = getEponymeSchemas(config);
|
|
13
13
|
this.collections = getEponymeCollections(config);
|
|
14
|
+
this.cacheMs = Math.max(0, options.cacheSeconds ?? 5) * 1e3;
|
|
14
15
|
}
|
|
15
16
|
schemas;
|
|
16
17
|
collections;
|
|
18
|
+
/** Entries a read has already persisted the normalized shape for, so it only attempts it once. */
|
|
19
|
+
healedByRead = /* @__PURE__ */ new Set();
|
|
20
|
+
/**
|
|
21
|
+
* Read-path cache of normalized rows, so a page that reads the same entry from a
|
|
22
|
+
* singleton, a listing and a layout pays one round trip instead of one per read.
|
|
23
|
+
* Writers never read from it, so the `updatedAt` they lock on always comes from the
|
|
24
|
+
* database and a stale key can never turn into a spurious conflict.
|
|
25
|
+
*/
|
|
26
|
+
cache = /* @__PURE__ */ new Map();
|
|
27
|
+
cacheMs;
|
|
28
|
+
cached(key, load) {
|
|
29
|
+
if (!this.cacheMs) return load();
|
|
30
|
+
const hit = this.cache.get(key);
|
|
31
|
+
if (hit && hit.expires > Date.now()) return hit.value;
|
|
32
|
+
const pending = load().catch((error) => {
|
|
33
|
+
this.cache.delete(key);
|
|
34
|
+
throw error;
|
|
35
|
+
});
|
|
36
|
+
this.cache.set(key, { value: pending, expires: Date.now() + this.cacheMs });
|
|
37
|
+
return pending;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Drops what a write to `name` invalidates: the entry itself, and the listing of the
|
|
41
|
+
* collection that contains it, whose row set just changed.
|
|
42
|
+
*/
|
|
43
|
+
invalidate(name) {
|
|
44
|
+
this.cache.delete(`row:${name}`);
|
|
45
|
+
const collection = this.getCollectionEntry(name)?.name;
|
|
46
|
+
if (collection) this.cache.delete(`rows:${collection}`);
|
|
47
|
+
}
|
|
17
48
|
getSchema(name) {
|
|
18
49
|
return this.schemas[name] ?? this.getCollectionEntry(name)?.definition.fields;
|
|
19
50
|
}
|
|
@@ -28,9 +59,9 @@ export class EponymeService {
|
|
|
28
59
|
if (slug && !slug.includes("/")) return { name: collectionName, slug, definition };
|
|
29
60
|
}
|
|
30
61
|
}
|
|
31
|
-
/** Reconcile every configured eponyme at application startup. */
|
|
62
|
+
/** Reconcile every configured eponyme at application startup. This is the one place that heals. */
|
|
32
63
|
async syncAll() {
|
|
33
|
-
await Promise.all(Object.keys(this.schemas).map((name) => this.loadState(name)));
|
|
64
|
+
await Promise.all(Object.keys(this.schemas).map((name) => this.loadState(name, { heal: true })));
|
|
34
65
|
}
|
|
35
66
|
reconcile(schema, value, mode) {
|
|
36
67
|
const persisted = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
@@ -66,20 +97,50 @@ export class EponymeService {
|
|
|
66
97
|
}
|
|
67
98
|
};
|
|
68
99
|
}
|
|
69
|
-
/**
|
|
70
|
-
|
|
100
|
+
/**
|
|
101
|
+
* A singleton row is created on first read, but only when it is genuinely missing.
|
|
102
|
+
* An `upsert` would send a write on every read instead, and each one costs a database
|
|
103
|
+
* round trip on the critical path of a public page.
|
|
104
|
+
*/
|
|
105
|
+
async loadSingletonRow(name, schema) {
|
|
106
|
+
const row = await this.client.eponyme.findUnique({ where: { name } });
|
|
107
|
+
if (row) return row;
|
|
108
|
+
try {
|
|
109
|
+
return await this.client.eponyme.create({ data: { name, data: this.createState(schema) } });
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (isPrismaError(error, "P2002")) return await this.client.eponyme.findUnique({ where: { name } });
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Reads the stored state along with the `updatedAt` it was read at, used as the optimistic lock token.
|
|
117
|
+
*
|
|
118
|
+
* `heal` persists the normalized state when it differs from what is stored. It has to stay on for
|
|
119
|
+
* writers, because healing moves `updatedAt` and so decides the lock token they are about to compare.
|
|
120
|
+
*
|
|
121
|
+
* `cache` is only ever set for published content. Draft reads serve the preview panel, which has to
|
|
122
|
+
* show a save immediately, and they come from the dashboard rather than from a public page — so
|
|
123
|
+
* there is nothing to gain by caching them and a stale preview to lose.
|
|
124
|
+
*/
|
|
125
|
+
async loadRow(name, { heal = false, cache = false } = {}) {
|
|
126
|
+
if (!cache || heal) return await this.readRow(name, heal);
|
|
127
|
+
return await this.cached(`row:${name}`, () => this.readRow(name, false));
|
|
128
|
+
}
|
|
129
|
+
async readRow(name, heal) {
|
|
71
130
|
const schema = this.getSchema(name);
|
|
72
131
|
if (!schema) return void 0;
|
|
73
|
-
const
|
|
74
|
-
const row = this.getCollectionEntry(name) ? await this.client.eponyme.findUnique({ where: { name } }) : await this.client.eponyme.upsert({ where: { name }, create: { name, data: defaults }, update: {} });
|
|
132
|
+
const row = this.getCollectionEntry(name) ? await this.client.eponyme.findUnique({ where: { name } }) : await this.loadSingletonRow(name, schema);
|
|
75
133
|
if (!row || row.deletedAt) return void 0;
|
|
76
134
|
const state = this.normalizeState(schema, row.data);
|
|
77
135
|
if (sameData(state, row.data)) return { state, updatedAt: row.updatedAt };
|
|
136
|
+
if (!heal && this.healedByRead.has(name)) return { state, updatedAt: row.updatedAt };
|
|
137
|
+
this.healedByRead.add(name);
|
|
78
138
|
const healed = await this.client.eponyme.update({ where: { name }, data: { data: state } });
|
|
139
|
+
this.invalidate(name);
|
|
79
140
|
return { state, updatedAt: healed.updatedAt ?? row.updatedAt };
|
|
80
141
|
}
|
|
81
|
-
async loadState(name) {
|
|
82
|
-
return (await this.loadRow(name))?.state;
|
|
142
|
+
async loadState(name, options = {}) {
|
|
143
|
+
return (await this.loadRow(name, options))?.state;
|
|
83
144
|
}
|
|
84
145
|
/**
|
|
85
146
|
* Writes a new state only if the row still carries the `updatedAt` we read.
|
|
@@ -88,19 +149,38 @@ export class EponymeService {
|
|
|
88
149
|
*/
|
|
89
150
|
async writeState(name, next, expectedUpdatedAt) {
|
|
90
151
|
const data = next;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
152
|
+
try {
|
|
153
|
+
if (!expectedUpdatedAt) {
|
|
154
|
+
await this.client.eponyme.update({ where: { name }, data: { data } });
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
const { count } = await this.client.eponyme.updateMany({ where: { name, updatedAt: expectedUpdatedAt }, data: { data } });
|
|
158
|
+
return count > 0;
|
|
159
|
+
} finally {
|
|
160
|
+
this.invalidate(name);
|
|
94
161
|
}
|
|
95
|
-
|
|
96
|
-
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The live rows of a collection, shared by the public listing and the sitemap so both
|
|
165
|
+
* read them once. The trash and the export deliberately query around it: one wants the
|
|
166
|
+
* rows this filter excludes, the other must not see a cached view of the content.
|
|
167
|
+
*/
|
|
168
|
+
async liveCollectionRows(name, cache = false) {
|
|
169
|
+
const load = () => this.client.eponyme.findMany({
|
|
170
|
+
where: { name: { startsWith: `${name}/` }, deletedAt: null },
|
|
171
|
+
orderBy: [
|
|
172
|
+
{ updatedAt: "desc" },
|
|
173
|
+
{ name: "asc" }
|
|
174
|
+
]
|
|
175
|
+
});
|
|
176
|
+
return cache ? await this.cached(`rows:${name}`, load) : await load();
|
|
97
177
|
}
|
|
98
178
|
async get(name, version = "published") {
|
|
99
179
|
return (await this.getResult(name, version))?.data;
|
|
100
180
|
}
|
|
101
181
|
async getResult(name, version = "published") {
|
|
102
182
|
if (typeof version === "number") return this.getVersionResult(name, version);
|
|
103
|
-
const state = await this.loadState(name);
|
|
183
|
+
const state = await this.loadState(name, { cache: version === "published" });
|
|
104
184
|
if (!state) return void 0;
|
|
105
185
|
if (version === "published" && this.getCollectionEntry(name) && !state.__eponyme.publishedAt) return void 0;
|
|
106
186
|
return {
|
|
@@ -138,25 +218,20 @@ export class EponymeService {
|
|
|
138
218
|
async listCollection(name, version = "published", query = {}) {
|
|
139
219
|
const definition = this.collections[name];
|
|
140
220
|
if (!definition) return void 0;
|
|
141
|
-
const rows = await this.
|
|
142
|
-
|
|
143
|
-
orderBy: [
|
|
144
|
-
{ updatedAt: "desc" },
|
|
145
|
-
{ name: "asc" }
|
|
146
|
-
]
|
|
147
|
-
});
|
|
148
|
-
const entries = await Promise.all(rows.filter((row) => !row.name.slice(name.length + 1).includes("/")).map(async (row) => {
|
|
221
|
+
const rows = await this.liveCollectionRows(name, version === "published");
|
|
222
|
+
const entries = rows.flatMap((row) => {
|
|
149
223
|
const slug = row.name.slice(name.length + 1);
|
|
150
|
-
|
|
151
|
-
|
|
224
|
+
if (!slug || slug.includes("/")) return [];
|
|
225
|
+
const state = this.normalizeState(definition.fields, row.data);
|
|
226
|
+
return [{
|
|
152
227
|
slug,
|
|
153
|
-
title: String(
|
|
154
|
-
data:
|
|
155
|
-
status: version === "published" ? "published" :
|
|
156
|
-
publishedAt:
|
|
228
|
+
title: String(state.__eponyme[version][definition.titleField] || slug),
|
|
229
|
+
data: state.__eponyme[version],
|
|
230
|
+
status: version === "published" ? "published" : state.__eponyme.status,
|
|
231
|
+
publishedAt: state.__eponyme.publishedAt,
|
|
157
232
|
updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null
|
|
158
|
-
};
|
|
159
|
-
})
|
|
233
|
+
}];
|
|
234
|
+
});
|
|
160
235
|
const visible = version === "draft" ? entries : entries.filter((entry) => entry.publishedAt);
|
|
161
236
|
const sorted = query.orderBy ? sortCollectionEntries(visible, query.orderBy, query.order ?? "desc") : visible;
|
|
162
237
|
const skip = Math.max(0, Math.trunc(query.skip ?? 0) || 0);
|
|
@@ -181,13 +256,7 @@ export class EponymeService {
|
|
|
181
256
|
}
|
|
182
257
|
if (!path.includes(":slug"))
|
|
183
258
|
throw new Error(`[Eponyme] previewPaths.${name} must include ":slug" to generate collection sitemap URLs.`);
|
|
184
|
-
const rows = await this.
|
|
185
|
-
where: { name: { startsWith: `${name}/` }, deletedAt: null },
|
|
186
|
-
orderBy: [
|
|
187
|
-
{ updatedAt: "desc" },
|
|
188
|
-
{ name: "asc" }
|
|
189
|
-
]
|
|
190
|
-
});
|
|
259
|
+
const rows = await this.liveCollectionRows(name, true);
|
|
191
260
|
return rows.flatMap((row) => {
|
|
192
261
|
const slug = row.name.slice(name.length + 1);
|
|
193
262
|
if (!slug || slug.includes("/")) return [];
|
|
@@ -288,6 +357,7 @@ export class EponymeService {
|
|
|
288
357
|
if (!options.dryRun) {
|
|
289
358
|
const data = state;
|
|
290
359
|
await this.client.eponyme.upsert({ where: { name: entry.name }, create: { name: entry.name, data }, update: { data } });
|
|
360
|
+
this.invalidate(entry.name);
|
|
291
361
|
await this.client.eponymeVersion.create({
|
|
292
362
|
data: {
|
|
293
363
|
entryName: entry.name,
|
|
@@ -330,6 +400,7 @@ export class EponymeService {
|
|
|
330
400
|
};
|
|
331
401
|
try {
|
|
332
402
|
await this.client.eponyme.create({ data: { name: entryName, data: state } });
|
|
403
|
+
this.invalidate(entryName);
|
|
333
404
|
} catch (error) {
|
|
334
405
|
if (isPrismaError(error, "P2002")) return await this.slugTakenError(definition, entryName) ?? { errors: { [definition.slugField]: ["This slug is already in use."] } };
|
|
335
406
|
throw error;
|
|
@@ -357,6 +428,7 @@ export class EponymeService {
|
|
|
357
428
|
where: { name, deletedAt: null },
|
|
358
429
|
data: { deletedAt: /* @__PURE__ */ new Date() }
|
|
359
430
|
});
|
|
431
|
+
this.invalidate(name);
|
|
360
432
|
return count > 0;
|
|
361
433
|
}
|
|
362
434
|
async restoreCollectionEntry(name) {
|
|
@@ -365,6 +437,7 @@ export class EponymeService {
|
|
|
365
437
|
where: { name, deletedAt: { not: null } },
|
|
366
438
|
data: { deletedAt: null }
|
|
367
439
|
});
|
|
440
|
+
this.invalidate(name);
|
|
368
441
|
return count > 0;
|
|
369
442
|
}
|
|
370
443
|
/** Definitive: `onDelete: Cascade` takes the entry's whole history with it. */
|
|
@@ -374,6 +447,7 @@ export class EponymeService {
|
|
|
374
447
|
if (!row?.deletedAt) return false;
|
|
375
448
|
try {
|
|
376
449
|
await this.client.eponyme.delete({ where: { name } });
|
|
450
|
+
this.invalidate(name);
|
|
377
451
|
} catch (error) {
|
|
378
452
|
if (isPrismaError(error, "P2025")) return false;
|
|
379
453
|
throw error;
|
|
@@ -410,7 +484,7 @@ export class EponymeService {
|
|
|
410
484
|
async patch(name, payload, action = "publish", actorId) {
|
|
411
485
|
const schema = this.getSchema(name);
|
|
412
486
|
if (!schema) return void 0;
|
|
413
|
-
const row = await this.loadRow(name);
|
|
487
|
+
const row = await this.loadRow(name, { heal: true });
|
|
414
488
|
if (!row) return void 0;
|
|
415
489
|
const { state, updatedAt } = row;
|
|
416
490
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return { errors: { _form: ["Body must be an object."] } };
|
|
@@ -467,7 +541,7 @@ export class EponymeService {
|
|
|
467
541
|
if (!schema) return void 0;
|
|
468
542
|
const version = await this.client.eponymeVersion.findUnique({ where: { id: versionId } });
|
|
469
543
|
if (!version || version.entryName !== name) return void 0;
|
|
470
|
-
const current = await this.loadRow(name);
|
|
544
|
+
const current = await this.loadRow(name, { heal: true });
|
|
471
545
|
if (!current) return void 0;
|
|
472
546
|
const state = this.normalizeState(schema, version.data);
|
|
473
547
|
if (!await this.writeState(name, state, current.updatedAt)) return { conflict: true };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { H3Event } from 'h3';
|
|
2
|
+
import type { EponymeCaptchaResult, EponymeCaptchaVerifier } from '../../types/captcha.js';
|
|
3
|
+
export declare const CAPTCHA_TOKEN_KEY = "_eponyme_captcha";
|
|
4
|
+
export declare function eponymeCaptchaVerifier(): EponymeCaptchaVerifier;
|
|
5
|
+
export declare function isEponymeCaptchaConfigured(): boolean;
|
|
6
|
+
export declare function verifyEponymeCaptcha(event: H3Event, token: unknown): Promise<EponymeCaptchaResult>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { getRequestIP } from "h3";
|
|
2
|
+
import captchaVerifier from "#eponyme/captcha";
|
|
3
|
+
export const CAPTCHA_TOKEN_KEY = "_eponyme_captcha";
|
|
4
|
+
export function eponymeCaptchaVerifier() {
|
|
5
|
+
return captchaVerifier;
|
|
6
|
+
}
|
|
7
|
+
export function isEponymeCaptchaConfigured() {
|
|
8
|
+
return eponymeCaptchaVerifier().name !== "none";
|
|
9
|
+
}
|
|
10
|
+
export async function verifyEponymeCaptcha(event, token) {
|
|
11
|
+
if (typeof token !== "string" || !token.trim())
|
|
12
|
+
return { success: false, reason: "Missing captcha token." };
|
|
13
|
+
return eponymeCaptchaVerifier().verify(token, { ip: getRequestIP(event, { xForwardedFor: true }) });
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface EponymeCaptchaResult {
|
|
2
|
+
success: boolean;
|
|
3
|
+
/** Provider-specific reason, surfaced in server logs rather than to the visitor. */
|
|
4
|
+
reason?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Verifies the token a visitor's captcha widget produced. Implemented by an adapter
|
|
8
|
+
* package such as `@karibsen/eponyme-captcha`; the core ships none, so a project
|
|
9
|
+
* without an adapter simply has no captcha.
|
|
10
|
+
*/
|
|
11
|
+
export interface EponymeCaptchaVerifier {
|
|
12
|
+
name: string;
|
|
13
|
+
verify: (token: string, context: {
|
|
14
|
+
ip?: string;
|
|
15
|
+
}) => Promise<EponymeCaptchaResult>;
|
|
16
|
+
}
|
|
File without changes
|
|
@@ -16,3 +16,10 @@ declare module '#eponyme/variables' {
|
|
|
16
16
|
const variables: EponymeVariables
|
|
17
17
|
export default variables
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
declare module '#eponyme/captcha' {
|
|
21
|
+
import type { EponymeCaptchaVerifier } from './captcha'
|
|
22
|
+
|
|
23
|
+
const verifier: EponymeCaptchaVerifier
|
|
24
|
+
export default verifier
|
|
25
|
+
}
|
|
@@ -42,6 +42,8 @@ export interface EponymeFormOptions<T extends EponymeFormSchema = EponymeFormSch
|
|
|
42
42
|
honeypot?: string | false;
|
|
43
43
|
/** In bytes. */
|
|
44
44
|
maxBodyBytes?: number;
|
|
45
|
+
/** Requires a valid captcha token. Needs an adapter such as `@karibsen/eponyme-captcha`. */
|
|
46
|
+
captcha?: boolean;
|
|
45
47
|
}
|
|
46
48
|
export interface EponymeFormDefinitionBase {
|
|
47
49
|
__eponymeForm: true;
|
|
@@ -51,6 +53,7 @@ export interface EponymeFormDefinitionBase {
|
|
|
51
53
|
submission: Required<EponymeFormSubmissionOptions>;
|
|
52
54
|
honeypot: string | false;
|
|
53
55
|
maxBodyBytes: number;
|
|
56
|
+
captcha: boolean;
|
|
54
57
|
}
|
|
55
58
|
export interface EponymeFormDefinition<T extends EponymeFormSchema = EponymeFormSchema> extends EponymeFormDefinitionBase {
|
|
56
59
|
fields: T;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { EponymeCaptchaVerifier } from '../types/captcha.js';
|
|
2
|
+
/**
|
|
3
|
+
* Stands in when no adapter is installed. It never claims success, so a form that
|
|
4
|
+
* requires a captcha refuses submissions rather than silently accepting them all.
|
|
5
|
+
*/
|
|
6
|
+
declare const verifier: EponymeCaptchaVerifier;
|
|
7
|
+
export default verifier;
|
|
@@ -14,5 +14,12 @@ export declare function summariseEponymeVariables(custom?: EponymeVariables): Ar
|
|
|
14
14
|
* than blanked, so a typo shows up on the page instead of silently deleting text.
|
|
15
15
|
*/
|
|
16
16
|
export declare function interpolateEponymeText(text: string, variables: Record<string, string>): string;
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Walks strings nested in objects and arrays, leaving every other value alone.
|
|
19
|
+
*
|
|
20
|
+
* A branch containing no `{{ name }}` is returned as-is rather than rebuilt, so content
|
|
21
|
+
* without variables — which is most of it — costs no copy. The result therefore shares
|
|
22
|
+
* structure with its input and must be treated as read-only, which is already true of
|
|
23
|
+
* everything the read routes return.
|
|
24
|
+
*/
|
|
18
25
|
export declare function interpolateEponymeValue<T>(value: T, variables: Record<string, string>): T;
|
|
@@ -42,11 +42,24 @@ export function interpolateEponymeText(text, variables) {
|
|
|
42
42
|
}
|
|
43
43
|
export function interpolateEponymeValue(value, variables) {
|
|
44
44
|
if (typeof value === "string") return interpolateEponymeText(value, variables);
|
|
45
|
-
if (Array.isArray(value))
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
let changed = false;
|
|
47
|
+
const next = value.map((item) => {
|
|
48
|
+
const interpolated = interpolateEponymeValue(item, variables);
|
|
49
|
+
if (interpolated !== item) changed = true;
|
|
50
|
+
return interpolated;
|
|
51
|
+
});
|
|
52
|
+
return changed ? next : value;
|
|
53
|
+
}
|
|
46
54
|
if (value && typeof value === "object") {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
55
|
+
let changed = false;
|
|
56
|
+
const next = {};
|
|
57
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
58
|
+
const interpolated = interpolateEponymeValue(nested, variables);
|
|
59
|
+
if (interpolated !== nested) changed = true;
|
|
60
|
+
next[key] = interpolated;
|
|
61
|
+
}
|
|
62
|
+
return changed ? next : value;
|
|
50
63
|
}
|
|
51
64
|
return value;
|
|
52
65
|
}
|