@karibsen/eponyme 0.1.1 → 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 +159 -12
- package/dist/eponyme.mjs +2 -1
- package/dist/module.d.mts +6 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +55 -36
- package/dist/runtime/components/editor/EponymeCollectionPage.vue +80 -18
- package/dist/runtime/components/editor/EponymeForm.vue +2 -2
- package/dist/runtime/components/editor/EponymeFormPage.vue +7 -3
- package/dist/runtime/components/editor/EponymeSidebar.vue +24 -32
- package/dist/runtime/components/editor/EponymeSidebarTree.d.vue.ts +2 -0
- package/dist/runtime/components/editor/EponymeSidebarTree.vue +3 -1
- package/dist/runtime/components/editor/EponymeSidebarTree.vue.d.ts +2 -0
- package/dist/runtime/components/fields/RichTextField.vue +51 -10
- package/dist/runtime/components/ui/EPLinkDialog.d.vue.ts +15 -0
- package/dist/runtime/components/ui/EPLinkDialog.vue +142 -0
- package/dist/runtime/components/ui/EPLinkDialog.vue.d.ts +15 -0
- package/dist/runtime/components/ui/EPLinkEditor.vue +10 -91
- package/dist/runtime/composables/useEponyme.js +5 -3
- package/dist/runtime/composables/useEponymeCollection.js +3 -2
- package/dist/runtime/composables/useEponymeForm.d.ts +4 -0
- package/dist/runtime/composables/useEponymeForm.js +11 -1
- package/dist/runtime/composables/useEponymeHistory.d.ts +2 -2
- package/dist/runtime/composables/useEponymeHistory.js +2 -1
- package/dist/runtime/pages/EponymeDetailPage.vue +1 -1
- package/dist/runtime/pages/EponymeIndexPage.vue +173 -10
- package/dist/runtime/server/api/eponyme/[name].patch.js +17 -1
- package/dist/runtime/server/api/eponyme-collections/[path].delete.js +7 -2
- package/dist/runtime/server/api/eponyme-export.get.d.ts +2 -0
- package/dist/runtime/server/api/eponyme-export.get.js +11 -0
- package/dist/runtime/server/api/eponyme-forms/[path].post.js +29 -1
- package/dist/runtime/server/api/eponyme-history/[path].patch.d.ts +1 -1
- package/dist/runtime/server/api/eponyme-history/[path].patch.js +14 -2
- package/dist/runtime/server/api/eponyme-import.post.d.ts +2 -0
- package/dist/runtime/server/api/eponyme-import.post.js +31 -0
- package/dist/runtime/server/api/eponyme-trash/[path].delete.d.ts +4 -0
- package/dist/runtime/server/api/eponyme-trash/[path].delete.js +16 -0
- package/dist/runtime/server/api/eponyme-trash/[path].get.d.ts +2 -0
- package/dist/runtime/server/api/eponyme-trash/[path].get.js +11 -0
- package/dist/runtime/server/api/eponyme-trash/[path].patch.d.ts +4 -0
- package/dist/runtime/server/api/eponyme-trash/[path].patch.js +16 -0
- 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 +118 -5
- package/dist/runtime/server/services/eponyme-store.js +344 -45
- package/dist/runtime/server/utils/eponyme-captcha.d.ts +6 -0
- package/dist/runtime/server/utils/eponyme-captcha.js +14 -0
- package/dist/runtime/server/utils/eponyme-entry.d.ts +9 -0
- package/dist/runtime/server/utils/eponyme-entry.js +7 -0
- package/dist/runtime/server/utils/eponyme-hooks.d.ts +16 -0
- package/dist/runtime/server/utils/eponyme-hooks.js +24 -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/field.d.ts +1 -0
- package/dist/runtime/types/hooks.d.ts +67 -0
- package/dist/runtime/types/hooks.js +0 -0
- package/dist/runtime/types/index.d.ts +1 -0
- package/dist/runtime/types/index.js +1 -0
- package/dist/runtime/types/nuxt.d.ts +10 -0
- package/dist/runtime/utils/build-navigation-tree.d.ts +22 -0
- package/dist/runtime/utils/build-navigation-tree.js +30 -0
- package/dist/runtime/utils/date-locale.d.ts +10 -0
- package/dist/runtime/utils/date-locale.js +1 -0
- package/dist/runtime/utils/downloadable-link.d.ts +2 -0
- package/dist/runtime/utils/downloadable-link.js +35 -0
- package/dist/runtime/utils/filter-navigation-tree.d.ts +7 -0
- package/dist/runtime/utils/filter-navigation-tree.js +39 -0
- package/dist/runtime/utils/get-field-default-value.js +1 -1
- package/dist/runtime/utils/hydration-cache.d.ts +11 -0
- package/dist/runtime/utils/hydration-cache.js +3 -0
- package/dist/runtime/utils/no-captcha.d.ts +7 -0
- package/dist/runtime/utils/no-captcha.js +5 -0
- package/dist/runtime/utils/validate-eponyme-data.js +1 -1
- package/dist/runtime/utils/variables.d.ts +8 -1
- package/dist/runtime/utils/variables.js +17 -4
- package/package.json +3 -1
|
@@ -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() {
|
|
@@ -47,7 +50,12 @@ export function useEponymeForm(name, options = {}) {
|
|
|
47
50
|
} else {
|
|
48
51
|
if (options.onSubmit && import.meta.dev)
|
|
49
52
|
console.warn(`[Eponyme] Form "${name}" is managed, so its onSubmit handler is ignored.`);
|
|
50
|
-
await requestFetch(`/api/eponyme-forms/${name}`, {
|
|
53
|
+
await requestFetch(`/api/eponyme-forms/${name}`, {
|
|
54
|
+
method: "POST",
|
|
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
|
|
58
|
+
});
|
|
51
59
|
}
|
|
52
60
|
submitted.value = true;
|
|
53
61
|
return true;
|
|
@@ -69,6 +77,8 @@ export function useEponymeForm(name, options = {}) {
|
|
|
69
77
|
pending,
|
|
70
78
|
submitted,
|
|
71
79
|
honeypot: definition.honeypot,
|
|
80
|
+
requiresCaptcha: definition.captcha,
|
|
81
|
+
captchaToken,
|
|
72
82
|
submit,
|
|
73
83
|
reset
|
|
74
84
|
};
|
|
@@ -2,7 +2,7 @@ import type { EponymeHistoryEntry } from '../server/services/eponyme-store.js';
|
|
|
2
2
|
export declare function useEponymeHistory(name: () => string): {
|
|
3
3
|
history: import("vue").Ref<{
|
|
4
4
|
id: number;
|
|
5
|
-
action: import("../server/services/eponyme-store.js").EponymeAction | "restore";
|
|
5
|
+
action: import("../server/services/eponyme-store.js").EponymeAction | "restore" | "import";
|
|
6
6
|
status: import("../server/services/eponyme-store.js").EponymeStatus;
|
|
7
7
|
createdAt: string;
|
|
8
8
|
user: {
|
|
@@ -11,7 +11,7 @@ export declare function useEponymeHistory(name: () => string): {
|
|
|
11
11
|
} | null;
|
|
12
12
|
}[], EponymeHistoryEntry[] | {
|
|
13
13
|
id: number;
|
|
14
|
-
action: import("../server/services/eponyme-store.js").EponymeAction | "restore";
|
|
14
|
+
action: import("../server/services/eponyme-store.js").EponymeAction | "restore" | "import";
|
|
15
15
|
status: import("../server/services/eponyme-store.js").EponymeStatus;
|
|
16
16
|
createdAt: string;
|
|
17
17
|
user: {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useRequestFetch } from "#app";
|
|
2
2
|
import { ref } from "vue";
|
|
3
|
+
import { EPONYME_DATE_LOCALE } from "../utils/date-locale.js";
|
|
3
4
|
export function useEponymeHistory(name) {
|
|
4
5
|
const requestFetch = useRequestFetch();
|
|
5
6
|
const history = ref([]);
|
|
@@ -20,5 +21,5 @@ export function useEponymeHistory(name) {
|
|
|
20
21
|
return { history, pending, error, load };
|
|
21
22
|
}
|
|
22
23
|
export function formatVersionDate(value) {
|
|
23
|
-
return new Intl.DateTimeFormat(
|
|
24
|
+
return new Intl.DateTimeFormat(EPONYME_DATE_LOCALE, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
|
24
25
|
}
|
|
@@ -38,7 +38,7 @@ if (!schemas[eponymeName.value] && !collection.value && !form.value && !collecti
|
|
|
38
38
|
<template>
|
|
39
39
|
<main class="eponyme-root ep:flex ep:min-h-screen ep:flex-col ep:bg-theme-ep ep:font-sans ep:text-text-ep ep:md:flex-row">
|
|
40
40
|
<EponymeSidebar :base-path="indexPath" />
|
|
41
|
-
<div class="ep:w-full">
|
|
41
|
+
<div class="ep:w-full ep:min-w-0 ep:md:flex-1">
|
|
42
42
|
<EponymeFolderPage
|
|
43
43
|
v-if="isFolder"
|
|
44
44
|
:base-path="indexPath"
|
|
@@ -1,34 +1,149 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import {
|
|
2
|
+
import { nextTick, ref } from "vue";
|
|
3
|
+
import { refreshNuxtData, useRequestFetch, useRoute, useSeoMeta } from "#app";
|
|
3
4
|
import EponymeNavigationLink from "../components/editor/EponymeNavigationLink.vue";
|
|
4
5
|
import EponymeSidebar from "../components/editor/EponymeSidebar.vue";
|
|
6
|
+
import EPButton from "../components/ui/EPButton.vue";
|
|
7
|
+
import EPDialog from "../components/ui/EPDialog.vue";
|
|
8
|
+
import EPToast from "../components/ui/EPToast.vue";
|
|
9
|
+
import { useEponymeAuth } from "../composables/useEponymeAuth";
|
|
5
10
|
import { useEponymeConfig } from "../composables/useEponymeConfig";
|
|
6
11
|
import { getEponymeCollections, getEponymeForms, getEponymeSchemas } from "../utils/get-eponyme-schemas";
|
|
7
12
|
const config = useEponymeConfig();
|
|
8
13
|
const route = useRoute();
|
|
14
|
+
const auth = useEponymeAuth();
|
|
15
|
+
const request = useRequestFetch();
|
|
9
16
|
const eponymes = getEponymeSchemas(config);
|
|
10
17
|
const collections = getEponymeCollections(config);
|
|
11
18
|
const forms = getEponymeForms(config);
|
|
12
19
|
const entries = { ...eponymes, ...collections, ...forms };
|
|
13
20
|
useSeoMeta({ title: "Eponyme" });
|
|
21
|
+
const fileInput = ref();
|
|
22
|
+
const exporting = ref(false);
|
|
23
|
+
const importing = ref(false);
|
|
24
|
+
const preview = ref();
|
|
25
|
+
const pendingFile = ref();
|
|
26
|
+
const toast = ref({ open: false, title: "", description: "", variant: "success" });
|
|
14
27
|
function label(name) {
|
|
15
28
|
return name.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
16
29
|
}
|
|
30
|
+
async function showToast(variant, title, description) {
|
|
31
|
+
toast.value.open = false;
|
|
32
|
+
await nextTick();
|
|
33
|
+
toast.value = { open: true, variant, title, description };
|
|
34
|
+
}
|
|
35
|
+
function describeError(caught, fallback) {
|
|
36
|
+
const error = caught;
|
|
37
|
+
const mismatch = error?.data?.data?.schemaMismatch;
|
|
38
|
+
const message = error?.statusMessage || error?.data?.statusMessage || fallback;
|
|
39
|
+
return mismatch?.length ? `${message} (${mismatch.join(", ")})` : message;
|
|
40
|
+
}
|
|
41
|
+
async function exportContent() {
|
|
42
|
+
exporting.value = true;
|
|
43
|
+
try {
|
|
44
|
+
const file = await request("/api/eponyme-export", { cache: "no-store" });
|
|
45
|
+
const url = URL.createObjectURL(new Blob([JSON.stringify(file, null, 2)], { type: "application/json" }));
|
|
46
|
+
const link = document.createElement("a");
|
|
47
|
+
link.href = url;
|
|
48
|
+
link.download = `eponyme-export-${file.eponyme.exportedAt.slice(0, 10)}.json`;
|
|
49
|
+
link.click();
|
|
50
|
+
URL.revokeObjectURL(url);
|
|
51
|
+
} catch (caught) {
|
|
52
|
+
await showToast("error", "Export failed", describeError(caught, "Unable to export the content."));
|
|
53
|
+
} finally {
|
|
54
|
+
exporting.value = false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function reviewFile(event) {
|
|
58
|
+
const input = event.target;
|
|
59
|
+
const file = input.files?.[0];
|
|
60
|
+
input.value = "";
|
|
61
|
+
if (!file) return;
|
|
62
|
+
importing.value = true;
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(await file.text());
|
|
65
|
+
preview.value = await request("/api/eponyme-import?dryRun=1", { method: "POST", body: parsed });
|
|
66
|
+
pendingFile.value = parsed;
|
|
67
|
+
} catch (caught) {
|
|
68
|
+
pendingFile.value = void 0;
|
|
69
|
+
preview.value = void 0;
|
|
70
|
+
await showToast("error", "Import refused", caught instanceof SyntaxError ? "This file is not valid JSON." : describeError(caught, "Unable to read this export file."));
|
|
71
|
+
} finally {
|
|
72
|
+
importing.value = false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function confirmImport() {
|
|
76
|
+
if (!pendingFile.value) return;
|
|
77
|
+
importing.value = true;
|
|
78
|
+
try {
|
|
79
|
+
const result = await request("/api/eponyme-import", { method: "POST", body: pendingFile.value });
|
|
80
|
+
closePreview();
|
|
81
|
+
await refreshNuxtData();
|
|
82
|
+
await showToast(
|
|
83
|
+
"success",
|
|
84
|
+
"Content imported",
|
|
85
|
+
`${result.created} created, ${result.updated} updated${result.skipped.length ? `, ${result.skipped.length} skipped` : ""}.`
|
|
86
|
+
);
|
|
87
|
+
} catch (caught) {
|
|
88
|
+
await showToast("error", "Import failed", describeError(caught, "Unable to import this file."));
|
|
89
|
+
} finally {
|
|
90
|
+
importing.value = false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function closePreview() {
|
|
94
|
+
preview.value = void 0;
|
|
95
|
+
pendingFile.value = void 0;
|
|
96
|
+
}
|
|
17
97
|
</script>
|
|
18
98
|
|
|
19
99
|
<template>
|
|
20
100
|
<main class="eponyme-root ep:flex ep:min-h-screen ep:flex-col ep:bg-theme-ep ep:font-sans ep:text-text-ep ep:md:flex-row">
|
|
21
101
|
<EponymeSidebar :base-path="route.path" />
|
|
22
102
|
<section class="ep:mx-auto ep:w-full ep:max-w-3xl ep:px-6 ep:py-8 ep:md:px-10 ep:md:py-12">
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
103
|
+
<div class="ep:flex ep:flex-wrap ep:items-start ep:justify-between ep:gap-4">
|
|
104
|
+
<div>
|
|
105
|
+
<p class="ep:m-0 ep:text-[11px] ep:font-semibold ep:tracking-widest ep:text-muted-ep ep:uppercase">
|
|
106
|
+
Eponyme
|
|
107
|
+
</p>
|
|
108
|
+
<h1 class="ep:mt-2 ep:text-3xl ep:font-semibold ep:tracking-tight ep:text-white">
|
|
109
|
+
Content entries
|
|
110
|
+
</h1>
|
|
111
|
+
<p class="ep:mt-2 ep:text-sm ep:text-muted-ep">
|
|
112
|
+
Choose the content area you want to update.
|
|
113
|
+
</p>
|
|
114
|
+
</div>
|
|
115
|
+
<div
|
|
116
|
+
v-if="auth.canEdit.value"
|
|
117
|
+
class="ep:flex ep:shrink-0 ep:items-center ep:gap-2"
|
|
118
|
+
>
|
|
119
|
+
<EPButton
|
|
120
|
+
label="Export"
|
|
121
|
+
icon="mingcute:download-2-line"
|
|
122
|
+
size="sm"
|
|
123
|
+
:loading="exporting"
|
|
124
|
+
:disabled="exporting"
|
|
125
|
+
title="Download every entry as a JSON file"
|
|
126
|
+
@click="exportContent"
|
|
127
|
+
/>
|
|
128
|
+
<EPButton
|
|
129
|
+
v-if="auth.isOwner.value"
|
|
130
|
+
label="Import"
|
|
131
|
+
icon="mingcute:upload-2-line"
|
|
132
|
+
size="sm"
|
|
133
|
+
:loading="importing"
|
|
134
|
+
:disabled="importing"
|
|
135
|
+
title="Apply an export file from another environment"
|
|
136
|
+
@click="fileInput?.click()"
|
|
137
|
+
/>
|
|
138
|
+
<input
|
|
139
|
+
ref="fileInput"
|
|
140
|
+
type="file"
|
|
141
|
+
accept="application/json,.json"
|
|
142
|
+
class="ep:hidden"
|
|
143
|
+
@change="reviewFile"
|
|
144
|
+
>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
32
147
|
<nav
|
|
33
148
|
class="ep:mt-8 ep:grid ep:gap-3 ep:sm:grid-cols-2"
|
|
34
149
|
aria-label="Content entries"
|
|
@@ -43,6 +158,54 @@ function label(name) {
|
|
|
43
158
|
/>
|
|
44
159
|
</nav>
|
|
45
160
|
</section>
|
|
161
|
+
<ClientOnly>
|
|
162
|
+
<EPDialog
|
|
163
|
+
:open="Boolean(preview)"
|
|
164
|
+
title="Import content"
|
|
165
|
+
description="Entries in the file overwrite their counterpart. Nothing is ever deleted."
|
|
166
|
+
@update:open="!$event && closePreview()"
|
|
167
|
+
>
|
|
168
|
+
<div v-if="preview">
|
|
169
|
+
<ul class="ep:m-0 ep:list-none ep:space-y-1 ep:p-0 ep:text-sm ep:text-muted-ep">
|
|
170
|
+
<li><span class="ep:font-semibold ep:text-white">{{ preview.created }}</span> to create</li>
|
|
171
|
+
<li><span class="ep:font-semibold ep:text-white">{{ preview.updated }}</span> to overwrite</li>
|
|
172
|
+
<li><span class="ep:font-semibold ep:text-white">{{ preview.skipped.length }}</span> skipped</li>
|
|
173
|
+
</ul>
|
|
174
|
+
<ul
|
|
175
|
+
v-if="preview.skipped.length"
|
|
176
|
+
class="ep:mt-4 ep:max-h-40 ep:list-none ep:space-y-2 ep:overflow-y-auto ep:p-0 ep:text-xs ep:text-muted-ep"
|
|
177
|
+
>
|
|
178
|
+
<li
|
|
179
|
+
v-for="skipped in preview.skipped"
|
|
180
|
+
:key="skipped.name"
|
|
181
|
+
>
|
|
182
|
+
<span class="ep:font-medium ep:text-white">{{ skipped.name }}</span> — {{ skipped.reason }}
|
|
183
|
+
</li>
|
|
184
|
+
</ul>
|
|
185
|
+
<div class="ep:mt-6 ep:flex ep:justify-end ep:gap-2">
|
|
186
|
+
<EPButton
|
|
187
|
+
label="Cancel"
|
|
188
|
+
variant="ghost"
|
|
189
|
+
@click="closePreview"
|
|
190
|
+
/>
|
|
191
|
+
<EPButton
|
|
192
|
+
label="Import"
|
|
193
|
+
variant="primary"
|
|
194
|
+
:loading="importing"
|
|
195
|
+
:disabled="importing || !preview.created && !preview.updated"
|
|
196
|
+
@click="confirmImport"
|
|
197
|
+
/>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</EPDialog>
|
|
201
|
+
<EPToast
|
|
202
|
+
:open="toast.open"
|
|
203
|
+
:title="toast.title"
|
|
204
|
+
:description="toast.description"
|
|
205
|
+
:variant="toast.variant"
|
|
206
|
+
@update:open="toast.open = $event"
|
|
207
|
+
/>
|
|
208
|
+
</ClientOnly>
|
|
46
209
|
</main>
|
|
47
210
|
</template>
|
|
48
211
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createError, defineEventHandler, getQuery, getRequestURL, readBody, setResponseStatus } from "h3";
|
|
2
2
|
import { useEponymeService } from "../../services/eponyme-service.js";
|
|
3
3
|
import { assertEponymeMutationOrigin, requireEponymeUser } from "../../utils/auth.js";
|
|
4
|
+
import { callEponymeBlockingHook, callEponymeHook } from "../../utils/eponyme-hooks.js";
|
|
5
|
+
import { splitEponymeCollectionEntry } from "../../utils/eponyme-entry.js";
|
|
4
6
|
export default defineEventHandler(async (event) => {
|
|
5
7
|
assertEponymeMutationOrigin(event);
|
|
6
8
|
const user = await requireEponymeUser(event, { roles: ["editor", "owner"] });
|
|
@@ -8,7 +10,12 @@ export default defineEventHandler(async (event) => {
|
|
|
8
10
|
if (!name) throw createError({ statusCode: 404, statusMessage: "Eponyme entry not found." });
|
|
9
11
|
const requestedAction = getQuery(event).action;
|
|
10
12
|
const action = requestedAction === "draft" ? "draft" : "publish";
|
|
11
|
-
const
|
|
13
|
+
const service = useEponymeService();
|
|
14
|
+
const collection = splitEponymeCollectionEntry(service, name);
|
|
15
|
+
const body = await readBody(event);
|
|
16
|
+
const beforeSave = { name, collection, action, data: body, userId: user.id };
|
|
17
|
+
await callEponymeBlockingHook("eponyme:entry:beforeSave", beforeSave);
|
|
18
|
+
const result = await service.patch(name, beforeSave.data, action, user.id);
|
|
12
19
|
if (!result) throw createError({ statusCode: 404, statusMessage: "Eponyme entry not found." });
|
|
13
20
|
if ("conflict" in result && result.conflict)
|
|
14
21
|
throw createError({ statusCode: 409, message: "This entry was changed by someone else. Reload the page to get the latest version." });
|
|
@@ -16,5 +23,14 @@ export default defineEventHandler(async (event) => {
|
|
|
16
23
|
setResponseStatus(event, 422);
|
|
17
24
|
return { errors: result.errors };
|
|
18
25
|
}
|
|
26
|
+
await callEponymeHook(action === "publish" ? "eponyme:entry:published" : "eponyme:entry:saved", {
|
|
27
|
+
name,
|
|
28
|
+
collection,
|
|
29
|
+
action,
|
|
30
|
+
status: result.status,
|
|
31
|
+
publishedAt: result.publishedAt,
|
|
32
|
+
data: result.data,
|
|
33
|
+
userId: user.id
|
|
34
|
+
});
|
|
19
35
|
return requestedAction ? result : { data: result.data };
|
|
20
36
|
});
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { createError, defineEventHandler, getRequestURL } from "h3";
|
|
2
2
|
import { useEponymeService } from "../../services/eponyme-service.js";
|
|
3
3
|
import { assertEponymeMutationOrigin, requireEponymeUser } from "../../utils/auth.js";
|
|
4
|
+
import { callEponymeHook } from "../../utils/eponyme-hooks.js";
|
|
5
|
+
import { splitEponymeCollectionEntry } from "../../utils/eponyme-entry.js";
|
|
4
6
|
export default defineEventHandler(async (event) => {
|
|
5
7
|
assertEponymeMutationOrigin(event);
|
|
6
|
-
await requireEponymeUser(event, { roles: ["editor", "owner"] });
|
|
8
|
+
const user = await requireEponymeUser(event, { roles: ["editor", "owner"] });
|
|
7
9
|
const name = decodeURIComponent(getRequestURL(event).pathname.replace(/^\/api\/eponyme-collections\//, ""));
|
|
8
|
-
|
|
10
|
+
const service = useEponymeService();
|
|
11
|
+
const collection = name ? splitEponymeCollectionEntry(service, name) : void 0;
|
|
12
|
+
if (!collection || !await service.deleteCollectionEntry(name))
|
|
9
13
|
throw createError({ statusCode: 404, statusMessage: "Eponyme collection entry not found." });
|
|
14
|
+
await callEponymeHook("eponyme:entry:trashed", { name, collection, userId: user.id });
|
|
10
15
|
return { deleted: true };
|
|
11
16
|
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineEventHandler, setResponseHeader } from "h3";
|
|
2
|
+
import { useEponymeService } from "../services/eponyme-service.js";
|
|
3
|
+
import { requireEponymeUser } from "../utils/auth.js";
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
await requireEponymeUser(event, { roles: ["editor", "owner"] });
|
|
6
|
+
setResponseHeader(event, "Cache-Control", "no-store");
|
|
7
|
+
const file = await useEponymeService().exportContent();
|
|
8
|
+
const day = file.eponyme.exportedAt.slice(0, 10);
|
|
9
|
+
setResponseHeader(event, "Content-Disposition", `attachment; filename="eponyme-export-${day}.json"`);
|
|
10
|
+
return file;
|
|
11
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
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";
|
|
5
|
+
import { callEponymeBlockingHook, callEponymeHook } from "../../utils/eponyme-hooks.js";
|
|
4
6
|
export default defineEventHandler(async (event) => {
|
|
5
7
|
const route = readEponymeFormRoute(event);
|
|
6
8
|
const service = useEponymeFormService();
|
|
@@ -25,12 +27,38 @@ export default defineEventHandler(async (event) => {
|
|
|
25
27
|
setResponseStatus(event, 201);
|
|
26
28
|
return { submitted: true };
|
|
27
29
|
}
|
|
28
|
-
|
|
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
|
+
}
|
|
44
|
+
const validated = service.validate(route.name, payload);
|
|
45
|
+
if (validated && "errors" in validated) {
|
|
46
|
+
setResponseStatus(event, 422);
|
|
47
|
+
return { errors: validated.errors };
|
|
48
|
+
}
|
|
49
|
+
const beforeSubmit = { form: route.name, data: validated?.data ?? {} };
|
|
50
|
+
await callEponymeBlockingHook("eponyme:form:beforeSubmit", beforeSubmit);
|
|
51
|
+
const result = await service.submit(route.name, beforeSubmit.data);
|
|
29
52
|
if (!result) throw createError({ statusCode: 404, statusMessage: "Eponyme form not found." });
|
|
30
53
|
if ("errors" in result) {
|
|
31
54
|
setResponseStatus(event, 422);
|
|
32
55
|
return { errors: result.errors };
|
|
33
56
|
}
|
|
57
|
+
await callEponymeHook("eponyme:form:submitted", {
|
|
58
|
+
form: route.name,
|
|
59
|
+
data: result.submission.data,
|
|
60
|
+
id: result.submission.id
|
|
61
|
+
});
|
|
34
62
|
setResponseStatus(event, 201);
|
|
35
63
|
return { submitted: true, id: result.submission.id };
|
|
36
64
|
});
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<import("../../services/eponyme-store.js").EponymeResult
|
|
1
|
+
declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<import("../../services/eponyme-store.js").EponymeResult>>;
|
|
2
2
|
export default _default;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createError, defineEventHandler, getRequestURL } from "h3";
|
|
2
2
|
import { useEponymeService } from "../../services/eponyme-service.js";
|
|
3
3
|
import { assertEponymeMutationOrigin, requireEponymeUser } from "../../utils/auth.js";
|
|
4
|
+
import { callEponymeHook } from "../../utils/eponyme-hooks.js";
|
|
5
|
+
import { splitEponymeCollectionEntry } from "../../utils/eponyme-entry.js";
|
|
4
6
|
export default defineEventHandler(async (event) => {
|
|
5
7
|
assertEponymeMutationOrigin(event);
|
|
6
8
|
const user = await requireEponymeUser(event, { roles: ["editor", "owner"] });
|
|
@@ -10,9 +12,19 @@ export default defineEventHandler(async (event) => {
|
|
|
10
12
|
const [, name, rawVersionId] = match;
|
|
11
13
|
const versionId = Number(rawVersionId);
|
|
12
14
|
if (!Number.isSafeInteger(versionId)) throw createError({ statusCode: 404, statusMessage: "Eponyme version not found." });
|
|
13
|
-
const
|
|
15
|
+
const service = useEponymeService();
|
|
16
|
+
const result = await service.restore(name, versionId, user.id);
|
|
14
17
|
if (!result) throw createError({ statusCode: 404, statusMessage: "Eponyme version not found." });
|
|
15
|
-
if ("conflict" in result
|
|
18
|
+
if ("conflict" in result)
|
|
16
19
|
throw createError({ statusCode: 409, message: "This entry was changed by someone else. Reload the page to get the latest version." });
|
|
20
|
+
await callEponymeHook("eponyme:entry:restored", {
|
|
21
|
+
name,
|
|
22
|
+
collection: splitEponymeCollectionEntry(service, name),
|
|
23
|
+
action: "restore",
|
|
24
|
+
status: result.status,
|
|
25
|
+
publishedAt: result.publishedAt,
|
|
26
|
+
data: result.data,
|
|
27
|
+
userId: user.id
|
|
28
|
+
});
|
|
17
29
|
return result;
|
|
18
30
|
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createError, defineEventHandler, getQuery, getRequestHeader, readRawBody, setResponseHeader } from "h3";
|
|
2
|
+
import { useEponymeService } from "../services/eponyme-service.js";
|
|
3
|
+
import { assertEponymeMutationOrigin, requireEponymeUser } from "../utils/auth.js";
|
|
4
|
+
const MAX_BODY_BYTES = 5 * 1024 * 1024;
|
|
5
|
+
export default defineEventHandler(async (event) => {
|
|
6
|
+
assertEponymeMutationOrigin(event);
|
|
7
|
+
const user = await requireEponymeUser(event, { roles: ["owner"] });
|
|
8
|
+
setResponseHeader(event, "Cache-Control", "no-store");
|
|
9
|
+
const declaredLength = Number(getRequestHeader(event, "content-length"));
|
|
10
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES)
|
|
11
|
+
throw createError({ statusCode: 413, statusMessage: "Import file is too large." });
|
|
12
|
+
const raw = await readRawBody(event, "utf8");
|
|
13
|
+
if (raw && Buffer.byteLength(raw, "utf8") > MAX_BODY_BYTES)
|
|
14
|
+
throw createError({ statusCode: 413, statusMessage: "Import file is too large." });
|
|
15
|
+
let payload;
|
|
16
|
+
try {
|
|
17
|
+
payload = raw ? JSON.parse(raw) : void 0;
|
|
18
|
+
} catch {
|
|
19
|
+
throw createError({ statusCode: 400, statusMessage: "Import file must be valid JSON." });
|
|
20
|
+
}
|
|
21
|
+
const dryRun = Boolean(getQuery(event).dryRun);
|
|
22
|
+
const result = await useEponymeService().importContent(payload, { dryRun, actorId: user.id });
|
|
23
|
+
if ("errors" in result) {
|
|
24
|
+
throw createError({
|
|
25
|
+
statusCode: result.schemaMismatch?.length ? 409 : 400,
|
|
26
|
+
statusMessage: result.errors[0] ?? "This import could not be applied.",
|
|
27
|
+
data: { errors: result.errors, schemaMismatch: result.schemaMismatch ?? [] }
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createError, defineEventHandler, getRequestURL } from "h3";
|
|
2
|
+
import { useEponymeService } from "../../services/eponyme-service.js";
|
|
3
|
+
import { assertEponymeMutationOrigin, requireEponymeUser } from "../../utils/auth.js";
|
|
4
|
+
import { callEponymeHook } from "../../utils/eponyme-hooks.js";
|
|
5
|
+
import { splitEponymeCollectionEntry } from "../../utils/eponyme-entry.js";
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
assertEponymeMutationOrigin(event);
|
|
8
|
+
const user = await requireEponymeUser(event, { roles: ["owner"] });
|
|
9
|
+
const name = decodeURIComponent(getRequestURL(event).pathname.replace(/^\/api\/eponyme-trash\//, ""));
|
|
10
|
+
const service = useEponymeService();
|
|
11
|
+
const collection = name ? splitEponymeCollectionEntry(service, name) : void 0;
|
|
12
|
+
if (!collection || !await service.purgeCollectionEntry(name))
|
|
13
|
+
throw createError({ statusCode: 404, statusMessage: "Eponyme trashed entry not found." });
|
|
14
|
+
await callEponymeHook("eponyme:entry:purged", { name, collection, userId: user.id });
|
|
15
|
+
return { purged: true };
|
|
16
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createError, defineEventHandler, getRequestURL } from "h3";
|
|
2
|
+
import { useEponymeService } from "../../services/eponyme-service.js";
|
|
3
|
+
import { requireEponymeUser } from "../../utils/auth.js";
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
await requireEponymeUser(event, { roles: ["editor", "owner"] });
|
|
6
|
+
const name = decodeURIComponent(getRequestURL(event).pathname.replace(/^\/api\/eponyme-trash\//, ""));
|
|
7
|
+
if (!name) throw createError({ statusCode: 404, statusMessage: "Eponyme collection not found." });
|
|
8
|
+
const page = await useEponymeService().listCollectionTrash(name);
|
|
9
|
+
if (!page) throw createError({ statusCode: 404, statusMessage: "Eponyme collection not found." });
|
|
10
|
+
return page;
|
|
11
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createError, defineEventHandler, getRequestURL } from "h3";
|
|
2
|
+
import { useEponymeService } from "../../services/eponyme-service.js";
|
|
3
|
+
import { assertEponymeMutationOrigin, requireEponymeUser } from "../../utils/auth.js";
|
|
4
|
+
import { callEponymeHook } from "../../utils/eponyme-hooks.js";
|
|
5
|
+
import { splitEponymeCollectionEntry } from "../../utils/eponyme-entry.js";
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
assertEponymeMutationOrigin(event);
|
|
8
|
+
const user = await requireEponymeUser(event, { roles: ["editor", "owner"] });
|
|
9
|
+
const name = decodeURIComponent(getRequestURL(event).pathname.replace(/^\/api\/eponyme-trash\//, ""));
|
|
10
|
+
const service = useEponymeService();
|
|
11
|
+
const collection = name ? splitEponymeCollectionEntry(service, name) : void 0;
|
|
12
|
+
if (!collection || !await service.restoreCollectionEntry(name))
|
|
13
|
+
throw createError({ statusCode: 404, statusMessage: "Eponyme trashed entry not found." });
|
|
14
|
+
await callEponymeHook("eponyme:entry:untrashed", { name, collection, userId: user.id });
|
|
15
|
+
return { restored: true };
|
|
16
|
+
});
|
|
@@ -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
|
}
|