@karibsen/eponyme 0.2.0 → 0.4.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 +81 -5
- package/dist/eponyme.mjs +2 -1
- package/dist/module.d.mts +18 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +15 -1
- package/dist/runtime/composables/useEponyme.js +2 -1
- package/dist/runtime/composables/useEponymeCollection.js +7 -2
- package/dist/runtime/composables/useEponymeForm.d.ts +4 -0
- package/dist/runtime/composables/useEponymeForm.js +8 -1
- package/dist/runtime/server/api/eponyme/[name].get.js +6 -6
- package/dist/runtime/server/api/eponyme-collections/[path].get.js +2 -0
- package/dist/runtime/server/api/eponyme-forms/[path].post.js +15 -0
- package/dist/runtime/server/api/eponyme-sitemap.get.js +3 -1
- package/dist/runtime/server/middleware/eponyme-no-store.d.ts +14 -0
- package/dist/runtime/server/middleware/eponyme-no-store.js +6 -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-cache.d.ts +16 -0
- package/dist/runtime/server/utils/eponyme-cache.js +20 -0
- 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
|
@@ -559,6 +559,49 @@ ${entries.map(entry => ` <url><loc>https://example.com${entry.loc}</loc>${entry
|
|
|
559
559
|
})
|
|
560
560
|
```
|
|
561
561
|
|
|
562
|
+
## Caching
|
|
563
|
+
|
|
564
|
+
Published content is the same for every visitor, so it is cached at three levels. The
|
|
565
|
+
defaults suit a content site and can be tuned or switched off:
|
|
566
|
+
|
|
567
|
+
```ts
|
|
568
|
+
export default defineNuxtConfig({
|
|
569
|
+
eponyme: {
|
|
570
|
+
prismaClient: '~~/server/utils/prisma',
|
|
571
|
+
cacheSeconds: 5, // server instance
|
|
572
|
+
browserCacheSeconds: 30, // visitor's browser
|
|
573
|
+
cdnCacheSeconds: 300, // CDN or edge
|
|
574
|
+
},
|
|
575
|
+
})
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
| Option | Who holds the copy | Purgeable |
|
|
579
|
+
|---|---|---|
|
|
580
|
+
| `cacheSeconds` | The server instance, in memory | Yes — cleared on every write |
|
|
581
|
+
| `browserCacheSeconds` | The visitor's browser | **No** |
|
|
582
|
+
| `cdnCacheSeconds` | The CDN | Yes — see below |
|
|
583
|
+
|
|
584
|
+
`browserCacheSeconds` is what makes a client-side navigation instant: the browser answers
|
|
585
|
+
from its own cache instead of crossing the network. It is also the one window nobody can
|
|
586
|
+
shorten. A visitor holding a copy keeps it until it expires, publication or not, which is
|
|
587
|
+
why the default is deliberately small. Set it to `0` if a publication must be visible
|
|
588
|
+
immediately to everyone; navigation then costs a round trip again.
|
|
589
|
+
|
|
590
|
+
The CDN window can be long because it can be purged, and `eponyme:entry:published` is the
|
|
591
|
+
place to do it:
|
|
592
|
+
|
|
593
|
+
```ts
|
|
594
|
+
nitroApp.hooks.hook('eponyme:entry:published', async ({ name, collection }) => {
|
|
595
|
+
await purgeCdnCache(collection ? `/${collection.name}/${collection.slug}` : `/${name}`)
|
|
596
|
+
})
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
**Only published content is ever cached.** Drafts, historical versions, `raw=1` reads,
|
|
600
|
+
submissions, the trash, the user list and the session route all answer `no-store`. That is
|
|
601
|
+
enforced by a server middleware that marks every Eponyme route uncacheable, which the few
|
|
602
|
+
public ones override — so a route added later is private until someone decides otherwise,
|
|
603
|
+
rather than public until someone remembers.
|
|
604
|
+
|
|
562
605
|
## Forms
|
|
563
606
|
|
|
564
607
|
Use `form()` for public forms. A form owns a schema, its own routes and its anti-abuse
|
|
@@ -668,10 +711,43 @@ Two protections apply to the public submission route:
|
|
|
668
711
|
- a **body size limit**, 64 KB by default, configurable with `maxBodyBytes`. An oversized
|
|
669
712
|
body is refused with `413` before it is parsed.
|
|
670
713
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
714
|
+
### Captcha
|
|
715
|
+
|
|
716
|
+
Set `captcha: true` on the form, then install an adapter — Eponyme defines the contract
|
|
717
|
+
but ships no provider:
|
|
718
|
+
|
|
719
|
+
```bash
|
|
720
|
+
pnpm add @karibsen/eponyme-captcha
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
```ts
|
|
724
|
+
export default defineNuxtConfig({
|
|
725
|
+
modules: ['@karibsen/eponyme', '@karibsen/eponyme-captcha'],
|
|
726
|
+
eponymeCaptcha: { siteKey: '<your-turnstile-site-key>' },
|
|
727
|
+
})
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
Render the widget and bind its token; `useEponymeForm` sends it alongside the fields and
|
|
731
|
+
the server verifies it before validating anything:
|
|
732
|
+
|
|
733
|
+
```vue
|
|
734
|
+
<script setup lang="ts">
|
|
735
|
+
const { captchaToken, requiresCaptcha, submit } = useEponymeForm('contact')
|
|
736
|
+
</script>
|
|
737
|
+
|
|
738
|
+
<template>
|
|
739
|
+
<NuxtTurnstile v-if="requiresCaptcha" v-model="captchaToken" />
|
|
740
|
+
</template>
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
**It fails closed.** Without an adapter, a form declaring `captcha: true` answers `500`
|
|
744
|
+
rather than accepting submissions unprotected. A rejected token answers `422` with a
|
|
745
|
+
`_form` error; the provider's reason stays in the server log, since telling a bot which
|
|
746
|
+
check it failed only helps it.
|
|
747
|
+
|
|
748
|
+
Writing an adapter for another provider means supplying a `name` and an
|
|
749
|
+
`async verify(token, { ip })` returning `{ success, reason? }`, then pointing the
|
|
750
|
+
`#eponyme/captcha` alias at it.
|
|
675
751
|
|
|
676
752
|
Rate limiting and file uploads are not implemented yet.
|
|
677
753
|
|
|
@@ -730,7 +806,7 @@ a trash move leaves the entry itself untouched.
|
|
|
730
806
|
|
|
731
807
|
## Current status
|
|
732
808
|
|
|
733
|
-
Eponyme is at version `0.
|
|
809
|
+
Eponyme is at version `0.4.0`. It is ready for controlled projects and production pilots. A few workflows still need hardening before a broad public release:
|
|
734
810
|
|
|
735
811
|
- 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
812
|
- 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,24 @@ 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;
|
|
29
|
+
/**
|
|
30
|
+
* How long a browser may reuse published content, in seconds. This is what makes a
|
|
31
|
+
* client-side navigation instant, and it is the one window nobody can purge: a visitor
|
|
32
|
+
* who already holds a copy keeps it until it expires. Keep it short. `0` disables it.
|
|
33
|
+
*/
|
|
34
|
+
browserCacheSeconds?: number;
|
|
35
|
+
/**
|
|
36
|
+
* How long a CDN may reuse published content, in seconds. A CDN can be purged — the
|
|
37
|
+
* `eponyme:entry:published` hook is the place to do it — so this can be generous.
|
|
38
|
+
* `0` disables it.
|
|
39
|
+
*/
|
|
40
|
+
cdnCacheSeconds?: number;
|
|
23
41
|
}
|
|
24
42
|
declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
|
|
25
43
|
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -15,7 +15,10 @@ const module$1 = defineNuxtModule({
|
|
|
15
15
|
colorPresets: [],
|
|
16
16
|
auth: {
|
|
17
17
|
sessionDurationDays: 7
|
|
18
|
-
}
|
|
18
|
+
},
|
|
19
|
+
cacheSeconds: 5,
|
|
20
|
+
browserCacheSeconds: 30,
|
|
21
|
+
cdnCacheSeconds: 300
|
|
19
22
|
},
|
|
20
23
|
moduleDependencies: {
|
|
21
24
|
"@nuxt/icon": {
|
|
@@ -70,6 +73,11 @@ const module$1 = defineNuxtModule({
|
|
|
70
73
|
nuxt.options.runtimeConfig.eponymeAuth = {
|
|
71
74
|
sessionDurationDays: options.auth?.sessionDurationDays ?? 7
|
|
72
75
|
};
|
|
76
|
+
nuxt.options.runtimeConfig.eponymeContent = {
|
|
77
|
+
cacheSeconds: options.cacheSeconds ?? 5,
|
|
78
|
+
browserCacheSeconds: options.browserCacheSeconds ?? 30,
|
|
79
|
+
cdnCacheSeconds: options.cdnCacheSeconds ?? 300
|
|
80
|
+
};
|
|
73
81
|
const preparing = Boolean(nuxt.options._prepare);
|
|
74
82
|
if (!options.prismaClient) {
|
|
75
83
|
if (!preparing) throw new Error(`${pc.red("[Eponyme]")} eponyme.prismaClient is required.`);
|
|
@@ -130,6 +138,7 @@ Note that '~/' points at ${nuxt.options.srcDir}, so a server module needs '~~/'.
|
|
|
130
138
|
nuxt.options.alias["#eponyme/prisma"] = prismaPath;
|
|
131
139
|
const variablesPath = await findPath(resolve(nuxt.options.rootDir, "eponyme.variables.ts"));
|
|
132
140
|
nuxt.options.alias["#eponyme/variables"] = variablesPath ?? resolver.resolve("./runtime/utils/empty-variables");
|
|
141
|
+
nuxt.options.alias["#eponyme/captcha"] ??= resolver.resolve("./runtime/utils/no-captcha");
|
|
133
142
|
addTypeTemplate({
|
|
134
143
|
filename: "types/eponyme-config.d.ts",
|
|
135
144
|
getContents: () => `declare module '#eponyme/config' {
|
|
@@ -140,8 +149,13 @@ declare module '#eponyme/variables' {
|
|
|
140
149
|
const variables: typeof import(${JSON.stringify(nuxt.options.alias["#eponyme/variables"])})['default']
|
|
141
150
|
export default variables
|
|
142
151
|
}
|
|
152
|
+
declare module '#eponyme/captcha' {
|
|
153
|
+
const verifier: import('${resolver.resolve("./runtime/types/captcha")}').EponymeCaptchaVerifier
|
|
154
|
+
export default verifier
|
|
155
|
+
}
|
|
143
156
|
`
|
|
144
157
|
});
|
|
158
|
+
addServerHandler({ middleware: true, handler: resolver.resolve("./runtime/server/middleware/eponyme-no-store") });
|
|
145
159
|
addServerHandler({ route: "/api/eponyme/**", handler: resolver.resolve("./runtime/server/api/eponyme/[name].get") });
|
|
146
160
|
addServerHandler({ route: "/api/eponyme/**", method: "patch", handler: resolver.resolve("./runtime/server/api/eponyme/[name].patch") });
|
|
147
161
|
addServerHandler({ route: "/api/eponyme-statuses", handler: resolver.resolve("./runtime/server/api/eponyme-statuses.get") });
|
|
@@ -10,9 +10,10 @@ export function useEponyme(name, options = {}) {
|
|
|
10
10
|
const saving = ref(false);
|
|
11
11
|
const requestFetch = useRequestFetch();
|
|
12
12
|
const cacheKey = `eponyme:${name}:${version}${options.raw ? ":raw" : ""}`;
|
|
13
|
+
const fetchCache = version === "published" && !options.raw ? void 0 : "no-store";
|
|
13
14
|
const { data: response, pending: loading, error, refresh: load } = useAsyncData(
|
|
14
15
|
cacheKey,
|
|
15
|
-
() => requestFetch(`/api/eponyme/${name}`, { query: { version, raw: options.raw ? 1 : void 0 }, cache:
|
|
16
|
+
() => requestFetch(`/api/eponyme/${name}`, { query: { version, raw: options.raw ? 1 : void 0 }, cache: fetchCache }),
|
|
16
17
|
{ getCachedData: cacheDuringHydrationOnly }
|
|
17
18
|
);
|
|
18
19
|
const data = computed(() => response.value?.data);
|
|
@@ -15,7 +15,8 @@ export function useEponymeCollection(name, options = {}) {
|
|
|
15
15
|
// The options belong in the key, otherwise two differently sorted calls to the
|
|
16
16
|
// same collection would share one cache entry.
|
|
17
17
|
`eponyme:collection:public:${name}:${options.take ?? ""}:${options.skip ?? ""}:${options.orderBy ?? ""}:${options.order ?? ""}`,
|
|
18
|
-
|
|
18
|
+
// Always the published listing, which is publicly cacheable: the browser cache may answer it.
|
|
19
|
+
() => requestFetch(`/api/eponyme-collections/${name}`, { query }),
|
|
19
20
|
{ getCachedData: cacheDuringHydrationOnly }
|
|
20
21
|
);
|
|
21
22
|
const entries = computed(() => result.data.value?.entries ?? []);
|
|
@@ -37,7 +38,11 @@ export function useEponymeCollectionEntry(name, slug, options = {}) {
|
|
|
37
38
|
const requestFetch = useRequestFetch();
|
|
38
39
|
const result = useAsyncData(
|
|
39
40
|
`eponyme:collection-entry:${name}:${slug}:${version}`,
|
|
40
|
-
() => requestFetch(`/api/eponyme/${name}/${encodeURIComponent(slug)}`, {
|
|
41
|
+
() => requestFetch(`/api/eponyme/${name}/${encodeURIComponent(slug)}`, {
|
|
42
|
+
query: { version },
|
|
43
|
+
// A draft or a historical version must never be stored, whatever the server says.
|
|
44
|
+
cache: version === "published" ? void 0 : "no-store"
|
|
45
|
+
}),
|
|
41
46
|
{ getCachedData: cacheDuringHydrationOnly }
|
|
42
47
|
);
|
|
43
48
|
if (import.meta.client) useEventListener(window, "focus", () => void result.refresh());
|
|
@@ -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
|
-
import { createError, defineEventHandler, getQuery, getRequestURL
|
|
1
|
+
import { createError, defineEventHandler, getQuery, getRequestURL } from "h3";
|
|
2
2
|
import { useEponymeService } from "../../services/eponyme-service.js";
|
|
3
3
|
import { requireEponymeUser } from "../../utils/auth.js";
|
|
4
|
+
import { setEponymePublicCache } from "../../utils/eponyme-cache.js";
|
|
4
5
|
import { interpolateEponymeContent } from "../../utils/eponyme-variables.js";
|
|
5
6
|
function readVersion(raw) {
|
|
6
7
|
if (raw === "draft") return "draft";
|
|
@@ -11,12 +12,11 @@ export default defineEventHandler(async (event) => {
|
|
|
11
12
|
const name = decodeURIComponent(getRequestURL(event).pathname.replace(/^\/api\/eponyme\//, ""));
|
|
12
13
|
const requestedVersion = getQuery(event).version;
|
|
13
14
|
const version = readVersion(requestedVersion);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
15
|
+
const raw = Boolean(getQuery(event).raw);
|
|
16
|
+
if (version !== "published") await requireEponymeUser(event);
|
|
17
|
+
else if (!raw) setEponymePublicCache(event);
|
|
18
18
|
const result = name ? await useEponymeService().getResult(name, version) : void 0;
|
|
19
19
|
if (!result) throw createError({ statusCode: 404, statusMessage: "Eponyme entry not found." });
|
|
20
|
-
const data =
|
|
20
|
+
const data = raw ? result.data : interpolateEponymeContent(result.data);
|
|
21
21
|
return requestedVersion ? { ...result, data } : { data };
|
|
22
22
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createError, defineEventHandler, getQuery, getRequestURL } from "h3";
|
|
2
2
|
import { useEponymeService } from "../../services/eponyme-service.js";
|
|
3
3
|
import { requireEponymeUser } from "../../utils/auth.js";
|
|
4
|
+
import { setEponymePublicCache } from "../../utils/eponyme-cache.js";
|
|
4
5
|
import { interpolateEponymeContent } from "../../utils/eponyme-variables.js";
|
|
5
6
|
const MAX_TAKE = 200;
|
|
6
7
|
function readCount(raw, min, max) {
|
|
@@ -14,6 +15,7 @@ export default defineEventHandler(async (event) => {
|
|
|
14
15
|
const query = getQuery(event);
|
|
15
16
|
const version = query.version === "draft" ? "draft" : "published";
|
|
16
17
|
if (version === "draft") await requireEponymeUser(event);
|
|
18
|
+
else if (!query.raw) setEponymePublicCache(event);
|
|
17
19
|
if (!name) throw createError({ statusCode: 404, statusMessage: "Eponyme collection not found." });
|
|
18
20
|
const service = useEponymeService();
|
|
19
21
|
const orderBy = query.orderBy === void 0 || query.orderBy === "" ? void 0 : String(query.orderBy);
|
|
@@ -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,5 +1,7 @@
|
|
|
1
1
|
import { defineEventHandler } from "h3";
|
|
2
|
+
import { setEponymePublicCache } from "../utils/eponyme-cache.js";
|
|
2
3
|
import { getEponymeSitemapEntries } from "../utils/eponyme-sitemap.js";
|
|
3
|
-
export default defineEventHandler(async () => {
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
setEponymePublicCache(event);
|
|
4
6
|
return { entries: await getEponymeSitemapEntries() };
|
|
5
7
|
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every Eponyme route is uncacheable until it says otherwise.
|
|
3
|
+
*
|
|
4
|
+
* The routes that may be cached are a short, deliberate list — published content, the
|
|
5
|
+
* collection listings, the sitemap — and each calls `setEponymePublicCache` to override
|
|
6
|
+
* this. Everything else carries sessions, drafts, trashed entries, form submissions or
|
|
7
|
+
* the user list, and none of it may ever be stored by a browser or a CDN.
|
|
8
|
+
*
|
|
9
|
+
* Written as a default rather than as a header on each handler so that the dangerous
|
|
10
|
+
* direction is the one that takes effort: a route added later is private until someone
|
|
11
|
+
* decides otherwise, instead of public until someone remembers.
|
|
12
|
+
*/
|
|
13
|
+
declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, void>;
|
|
14
|
+
export default _default;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { defineEventHandler, getRequestURL } from "h3";
|
|
2
|
+
import { setEponymePrivateCache } from "../utils/eponyme-cache.js";
|
|
3
|
+
export default defineEventHandler((event) => {
|
|
4
|
+
if (!getRequestURL(event).pathname.startsWith("/api/eponyme")) return;
|
|
5
|
+
setEponymePrivateCache(event);
|
|
6
|
+
});
|
|
@@ -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,16 @@
|
|
|
1
|
+
import type { H3Event } from 'h3';
|
|
2
|
+
/**
|
|
3
|
+
* Published content is identical for every visitor, so both the browser and the CDN may
|
|
4
|
+
* hold on to it. The two windows are deliberately different:
|
|
5
|
+
*
|
|
6
|
+
* - `s-maxage` is the CDN's, and a CDN can be purged — `eponyme:entry:published` is the
|
|
7
|
+
* hook to do it from, so this one can afford to be long.
|
|
8
|
+
* - `max-age` is the browser's, and a browser cache cannot be purged by anyone. Whatever
|
|
9
|
+
* it holds is served until it expires, publication or not, which is why it stays short.
|
|
10
|
+
*/
|
|
11
|
+
export declare function setEponymePublicCache(event: H3Event): void;
|
|
12
|
+
/**
|
|
13
|
+
* Drafts and historical versions are unreleased material. `no-store` rather than
|
|
14
|
+
* `no-cache`: nothing may keep a copy at all, not even one it promises to revalidate.
|
|
15
|
+
*/
|
|
16
|
+
export declare function setEponymePrivateCache(event: H3Event): void;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { setResponseHeader } from "h3";
|
|
2
|
+
import { useRuntimeConfig } from "nitropack/runtime";
|
|
3
|
+
const STALE_WHILE_REVALIDATE_FACTOR = 12;
|
|
4
|
+
export function setEponymePublicCache(event) {
|
|
5
|
+
const config = useRuntimeConfig().eponymeContent;
|
|
6
|
+
const browser = Math.max(0, Math.trunc(config?.browserCacheSeconds ?? 30));
|
|
7
|
+
const cdn = Math.max(0, Math.trunc(config?.cdnCacheSeconds ?? 300));
|
|
8
|
+
if (!browser && !cdn) {
|
|
9
|
+
setResponseHeader(event, "Cache-Control", "no-cache");
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
setResponseHeader(
|
|
13
|
+
event,
|
|
14
|
+
"Cache-Control",
|
|
15
|
+
`public, max-age=${browser}, s-maxage=${cdn}, stale-while-revalidate=${cdn * STALE_WHILE_REVALIDATE_FACTOR}`
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
export function setEponymePrivateCache(event) {
|
|
19
|
+
setResponseHeader(event, "Cache-Control", "no-store");
|
|
20
|
+
}
|
|
@@ -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
|
}
|