@xleddyl/nuxt-cms 0.1.9 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/module.json +1 -1
- package/dist/module.mjs +5 -1
- package/dist/runtime/app/components/cms/BlocksField.vue +59 -16
- package/dist/runtime/app/components/cms/ConfirmModal.d.vue.ts +20 -0
- package/dist/runtime/app/components/cms/ConfirmModal.vue +43 -0
- package/dist/runtime/app/components/cms/ConfirmModal.vue.d.ts +20 -0
- package/dist/runtime/app/components/cms/MediaGallery.vue +9 -2
- package/dist/runtime/app/components/cms/RelationField.vue +56 -10
- package/dist/runtime/app/components/cms/RichTextField.vue +63 -2
- package/dist/runtime/app/composables/cms-confirm.d.ts +4 -0
- package/dist/runtime/app/composables/cms-confirm.js +7 -0
- package/dist/runtime/app/i18n/en.json +32 -2
- package/dist/runtime/app/i18n/it.json +32 -2
- package/dist/runtime/app/pages/admin-collection.vue +89 -14
- package/dist/runtime/app/pages/admin-entry.vue +12 -5
- package/dist/runtime/server/api/collection.get.d.ts +1 -3
- package/dist/runtime/server/api/collection.get.js +34 -8
- package/dist/runtime/server/api/collection.post.js +11 -7
- package/dist/runtime/server/api/collection.put.js +11 -7
- package/dist/runtime/server/api/item.get.js +3 -2
- package/dist/runtime/server/api/item.put.js +12 -8
- package/dist/runtime/server/api/media-presign.post.js +2 -1
- package/dist/runtime/server/api/media.post.js +2 -1
- package/dist/runtime/server/plugins/migrate-postgres.js +9 -1
- package/dist/runtime/server/plugins/migrate-sqlite.js +9 -1
- package/dist/runtime/server/routes/auth/login.post.js +1 -1
- package/dist/runtime/server/utils/db-postgres.d.ts +4 -0
- package/dist/runtime/server/utils/db-postgres.js +3 -0
- package/dist/runtime/server/utils/db-sqlite.d.ts +2 -0
- package/dist/runtime/server/utils/db-sqlite.js +19 -0
- package/dist/runtime/server/utils/graphql.js +10 -4
- package/dist/runtime/server/utils/media.d.ts +1 -0
- package/dist/runtime/server/utils/media.js +27 -0
- package/dist/runtime/server/utils/relations.d.ts +3 -2
- package/dist/runtime/server/utils/relations.js +2 -4
- package/dist/runtime/server/utils/require-admin.js +15 -1
- package/dist/runtime/shared/index.d.ts +69 -1
- package/package.json +4 -1
|
@@ -32,7 +32,22 @@
|
|
|
32
32
|
</div>
|
|
33
33
|
</CmsPageHeader>
|
|
34
34
|
|
|
35
|
-
<
|
|
35
|
+
<CmsEmptyState
|
|
36
|
+
v-if="error"
|
|
37
|
+
icon="i-lucide-triangle-alert"
|
|
38
|
+
:title="t('cms.collection.loadError')"
|
|
39
|
+
>
|
|
40
|
+
<UButton
|
|
41
|
+
:label="t('cms.collection.retry')"
|
|
42
|
+
icon="i-lucide-refresh-cw"
|
|
43
|
+
variant="subtle"
|
|
44
|
+
class="mt-3 rounded-full px-4"
|
|
45
|
+
:loading="status === 'pending'"
|
|
46
|
+
@click="reload"
|
|
47
|
+
/>
|
|
48
|
+
</CmsEmptyState>
|
|
49
|
+
|
|
50
|
+
<div v-else-if="config.kind === 'single'" class="cms-card p-7">
|
|
36
51
|
<CmsEntryForm
|
|
37
52
|
v-model="formState"
|
|
38
53
|
:fields="config.fields"
|
|
@@ -43,6 +58,14 @@
|
|
|
43
58
|
</div>
|
|
44
59
|
|
|
45
60
|
<template v-else>
|
|
61
|
+
<UInput
|
|
62
|
+
v-if="total || searchTerm"
|
|
63
|
+
v-model="search"
|
|
64
|
+
icon="i-lucide-search"
|
|
65
|
+
:placeholder="t('cms.collection.searchPlaceholder')"
|
|
66
|
+
class="max-w-xs"
|
|
67
|
+
/>
|
|
68
|
+
|
|
46
69
|
<div v-if="rows.length" class="cms-card overflow-hidden">
|
|
47
70
|
<UTable
|
|
48
71
|
v-model:column-visibility="columnVisibility"
|
|
@@ -81,6 +104,19 @@
|
|
|
81
104
|
</UTable>
|
|
82
105
|
</div>
|
|
83
106
|
|
|
107
|
+
<div v-else-if="status === 'pending'" class="flex justify-center py-10">
|
|
108
|
+
<UIcon
|
|
109
|
+
name="i-lucide-loader-circle"
|
|
110
|
+
class="text-(--ui-text-dimmed) size-6 animate-spin"
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<CmsEmptyState
|
|
115
|
+
v-else-if="searchTerm"
|
|
116
|
+
icon="i-lucide-search-x"
|
|
117
|
+
:title="t('cms.collection.noResults')"
|
|
118
|
+
/>
|
|
119
|
+
|
|
84
120
|
<CmsEmptyState
|
|
85
121
|
v-else
|
|
86
122
|
icon="i-lucide-sprout"
|
|
@@ -95,6 +131,10 @@
|
|
|
95
131
|
@click="openCreate"
|
|
96
132
|
/>
|
|
97
133
|
</CmsEmptyState>
|
|
134
|
+
|
|
135
|
+
<div v-if="total > PAGE_SIZE" class="flex justify-center">
|
|
136
|
+
<UPagination v-model:page="page" :total="total" :items-per-page="PAGE_SIZE" />
|
|
137
|
+
</div>
|
|
98
138
|
</template>
|
|
99
139
|
</div>
|
|
100
140
|
</template>
|
|
@@ -115,6 +155,7 @@ import {
|
|
|
115
155
|
watch
|
|
116
156
|
} from "#imports";
|
|
117
157
|
import cmsConfig from "#cms-config";
|
|
158
|
+
import { useCmsConfirm } from "../composables/cms-confirm";
|
|
118
159
|
import { useCmsRuntime } from "../composables/cms-runtime";
|
|
119
160
|
import { cmsApi } from "../utils/api";
|
|
120
161
|
import { errorMessage } from "../utils/ui";
|
|
@@ -137,8 +178,37 @@ const fieldKeys = Object.keys(config.fields);
|
|
|
137
178
|
const drafts = config.kind === "collection" && !!config.drafts;
|
|
138
179
|
const formKeys = drafts ? [...fieldKeys, "status"] : fieldKeys;
|
|
139
180
|
const endpoint = `/api/cms/admin/${name}`;
|
|
140
|
-
const
|
|
141
|
-
const
|
|
181
|
+
const PAGE_SIZE = 25;
|
|
182
|
+
const page = ref(1);
|
|
183
|
+
const search = ref("");
|
|
184
|
+
const searchTerm = ref("");
|
|
185
|
+
let searchTimer;
|
|
186
|
+
watch(search, (value) => {
|
|
187
|
+
clearTimeout(searchTimer);
|
|
188
|
+
searchTimer = setTimeout(() => {
|
|
189
|
+
searchTerm.value = value.trim();
|
|
190
|
+
page.value = 1;
|
|
191
|
+
}, 300);
|
|
192
|
+
});
|
|
193
|
+
const listQuery = computed(() => ({
|
|
194
|
+
limit: PAGE_SIZE,
|
|
195
|
+
offset: (page.value - 1) * PAGE_SIZE,
|
|
196
|
+
...searchTerm.value ? { search: searchTerm.value } : {}
|
|
197
|
+
}));
|
|
198
|
+
const { data, refresh, error, status } = await useFetch(endpoint, {
|
|
199
|
+
query: config.kind === "collection" ? listQuery : void 0
|
|
200
|
+
});
|
|
201
|
+
function isList(value) {
|
|
202
|
+
return !!value && Array.isArray(value.items);
|
|
203
|
+
}
|
|
204
|
+
const rows = computed(() => isList(data.value) ? data.value.items : []);
|
|
205
|
+
const total = computed(() => isList(data.value) ? data.value.total : 0);
|
|
206
|
+
const reload = async () => {
|
|
207
|
+
await refresh();
|
|
208
|
+
if (config.kind === "single" && !error.value) {
|
|
209
|
+
formState.value = data.value && !isList(data.value) ? pickFields(data.value) : emptyState();
|
|
210
|
+
}
|
|
211
|
+
};
|
|
142
212
|
const tableUi = {
|
|
143
213
|
thead: "border-b border-(--cms-line)",
|
|
144
214
|
th: "cms-kicker px-5 py-3 font-normal",
|
|
@@ -154,15 +224,16 @@ function pickFields(row) {
|
|
|
154
224
|
return Object.fromEntries(formKeys.map((k) => [k, row[k] ?? null]));
|
|
155
225
|
}
|
|
156
226
|
async function submit(action) {
|
|
227
|
+
if (saving.value) return false;
|
|
157
228
|
saving.value = true;
|
|
158
229
|
try {
|
|
159
230
|
await action();
|
|
160
231
|
await refresh();
|
|
161
232
|
return true;
|
|
162
|
-
} catch (
|
|
233
|
+
} catch (error2) {
|
|
163
234
|
toast.add({
|
|
164
235
|
title: t("cms.toast.saveFailed"),
|
|
165
|
-
description: errorMessage(
|
|
236
|
+
description: errorMessage(error2),
|
|
166
237
|
color: "error"
|
|
167
238
|
});
|
|
168
239
|
return false;
|
|
@@ -171,7 +242,7 @@ async function submit(action) {
|
|
|
171
242
|
}
|
|
172
243
|
}
|
|
173
244
|
if (config.kind === "single") {
|
|
174
|
-
formState.value = data.value && !
|
|
245
|
+
formState.value = data.value && !isList(data.value) ? pickFields(data.value) : emptyState();
|
|
175
246
|
}
|
|
176
247
|
async function saveSingle() {
|
|
177
248
|
const ok = await submit(() => cmsApi(endpoint, { method: "PUT", body: formState.value }));
|
|
@@ -249,25 +320,29 @@ const columnItems = computed(
|
|
|
249
320
|
}))
|
|
250
321
|
);
|
|
251
322
|
const kicker = computed(
|
|
252
|
-
() => config.kind === "single" ? t("cms.collection.kickerSingle") : t("cms.collection.kicker",
|
|
323
|
+
() => config.kind === "single" ? t("cms.collection.kickerSingle") : t("cms.collection.kicker", total.value)
|
|
253
324
|
);
|
|
254
325
|
function onSelect(_event, row) {
|
|
255
326
|
navigateTo(`/cms/${name}/${row.original.id}`);
|
|
256
327
|
}
|
|
257
328
|
async function toggleStatus(row) {
|
|
258
329
|
const next = row.status === "published" ? "draft" : "published";
|
|
259
|
-
await submit(
|
|
260
|
-
|
|
330
|
+
await submit(async () => {
|
|
331
|
+
const fresh = await cmsApi(`${endpoint}/${row.id}`);
|
|
332
|
+
await cmsApi(`${endpoint}/${row.id}`, {
|
|
261
333
|
method: "PUT",
|
|
262
|
-
body: { ...pickFields(
|
|
263
|
-
})
|
|
264
|
-
);
|
|
334
|
+
body: { ...pickFields(fresh), status: next }
|
|
335
|
+
});
|
|
336
|
+
});
|
|
265
337
|
}
|
|
266
338
|
function openCreate() {
|
|
267
339
|
navigateTo(`/cms/${name}/new`);
|
|
268
340
|
}
|
|
341
|
+
const confirmAction = useCmsConfirm();
|
|
269
342
|
async function deleteRow(row) {
|
|
270
|
-
if (!
|
|
271
|
-
|
|
343
|
+
if (!await confirmAction(t("cms.collection.deleteConfirm"))) return;
|
|
344
|
+
const lastOnPage = rows.value.length === 1 && page.value > 1;
|
|
345
|
+
const ok = await submit(() => cmsApi(`${endpoint}/${row.id}`, { method: "DELETE" }));
|
|
346
|
+
if (ok && lastOnPage) page.value -= 1;
|
|
272
347
|
}
|
|
273
348
|
</script>
|
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
watch
|
|
70
70
|
} from "#imports";
|
|
71
71
|
import cmsConfig from "#cms-config";
|
|
72
|
+
import { useCmsConfirm } from "../composables/cms-confirm";
|
|
72
73
|
import { cmsApi } from "../utils/api";
|
|
73
74
|
import { errorMessage } from "../utils/ui";
|
|
74
75
|
const FORM_ID = "cms-entry-form";
|
|
@@ -100,9 +101,14 @@ function pickFields(row) {
|
|
|
100
101
|
}
|
|
101
102
|
const formState = ref(emptyState());
|
|
102
103
|
if (!isNew) {
|
|
103
|
-
const { data } = await useFetch(`${endpoint}/${id}`);
|
|
104
|
-
if (!data.value) {
|
|
105
|
-
|
|
104
|
+
const { data, error } = await useFetch(`${endpoint}/${id}`);
|
|
105
|
+
if (error.value || !data.value) {
|
|
106
|
+
const statusCode = error.value?.statusCode ?? 404;
|
|
107
|
+
throw createError({
|
|
108
|
+
statusCode,
|
|
109
|
+
statusMessage: statusCode === 404 ? "Entry not found" : "Failed to load entry",
|
|
110
|
+
fatal: true
|
|
111
|
+
});
|
|
106
112
|
}
|
|
107
113
|
formState.value = pickFields(data.value);
|
|
108
114
|
}
|
|
@@ -117,8 +123,9 @@ watch(dirty, (value) => {
|
|
|
117
123
|
else window.removeEventListener("beforeunload", onBeforeUnload);
|
|
118
124
|
});
|
|
119
125
|
onBeforeUnmount(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|
120
|
-
|
|
121
|
-
|
|
126
|
+
const confirmAction = useCmsConfirm();
|
|
127
|
+
onBeforeRouteLeave(async () => {
|
|
128
|
+
if (dirty.value && !await confirmAction(t("cms.entry.unsavedChanges"))) return false;
|
|
122
129
|
});
|
|
123
130
|
const published = computed({
|
|
124
131
|
get: () => formState.value.status === "published",
|
|
@@ -1,19 +1,45 @@
|
|
|
1
|
-
import { desc } from "drizzle-orm";
|
|
2
|
-
import { defineEventHandler } from "h3";
|
|
1
|
+
import { count, desc, sql } from "drizzle-orm";
|
|
2
|
+
import { defineEventHandler, getValidatedQuery } from "h3";
|
|
3
|
+
import { z } from "zod";
|
|
3
4
|
import { useDb } from "#cms-db";
|
|
4
5
|
import { getRegistryEntry, idColumn, tableColumns } from "../utils/registry.js";
|
|
5
6
|
import { attachManyToMany } from "../utils/relations.js";
|
|
6
7
|
import { requireAdmin } from "../utils/require-admin.js";
|
|
8
|
+
const querySchema = z.object({
|
|
9
|
+
limit: z.coerce.number().int().min(1).max(100).default(50),
|
|
10
|
+
offset: z.coerce.number().int().min(0).default(0),
|
|
11
|
+
search: z.string().trim().max(200).optional(),
|
|
12
|
+
light: z.stringbool().default(false)
|
|
13
|
+
});
|
|
14
|
+
function likePattern(term) {
|
|
15
|
+
return `%${term.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
|
|
16
|
+
}
|
|
7
17
|
export default defineEventHandler(async (event) => {
|
|
8
18
|
await requireAdmin(event);
|
|
9
19
|
const { name, entry, table } = getRegistryEntry(event);
|
|
10
20
|
const db = useDb();
|
|
11
21
|
if (entry.kind === "single") {
|
|
12
|
-
const
|
|
13
|
-
await attachManyToMany(name, entry,
|
|
14
|
-
return
|
|
22
|
+
const rows = await db.select().from(table).limit(1);
|
|
23
|
+
await attachManyToMany(db, name, entry, rows);
|
|
24
|
+
return rows[0] ?? null;
|
|
25
|
+
}
|
|
26
|
+
const { limit, offset, search, light } = await getValidatedQuery(event, querySchema.parse);
|
|
27
|
+
const columns = tableColumns(table);
|
|
28
|
+
const titleColumn = entry.titleField && Object.hasOwn(columns, entry.titleField) ? columns[entry.titleField] : void 0;
|
|
29
|
+
let where;
|
|
30
|
+
if (search) {
|
|
31
|
+
const column = titleColumn ?? idColumn(table);
|
|
32
|
+
where = sql`${column} like ${likePattern(search)} escape '\\'`;
|
|
15
33
|
}
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
34
|
+
const selection = light ? Object.fromEntries([
|
|
35
|
+
["id", idColumn(table)],
|
|
36
|
+
...entry.titleField && titleColumn ? [[entry.titleField, titleColumn]] : [],
|
|
37
|
+
...entry.drafts && Object.hasOwn(columns, "status") ? [["status", columns.status]] : []
|
|
38
|
+
]) : void 0;
|
|
39
|
+
const orderBy = columns.createdAt ?? idColumn(table);
|
|
40
|
+
const base = selection ? db.select(selection).from(table) : db.select().from(table);
|
|
41
|
+
const items = await (where ? base.where(where) : base).orderBy(desc(orderBy)).limit(limit).offset(offset);
|
|
42
|
+
const [counted] = await (where ? db.select({ total: count() }).from(table).where(where) : db.select({ total: count() }).from(table));
|
|
43
|
+
if (!light) await attachManyToMany(db, name, entry, items);
|
|
44
|
+
return { items, total: counted?.total ?? 0 };
|
|
19
45
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createError, defineEventHandler, readValidatedBody } from "h3";
|
|
2
|
-
import {
|
|
2
|
+
import { withTransaction } from "#cms-db";
|
|
3
3
|
import { customId } from "../utils/custom-id.js";
|
|
4
4
|
import { mapConstraintErrors } from "../utils/db-errors.js";
|
|
5
5
|
import { buildValidator, getRegistryEntry } from "../utils/registry.js";
|
|
@@ -21,10 +21,14 @@ export default defineEventHandler(async (event) => {
|
|
|
21
21
|
await assertRelationTargets(entry, lists);
|
|
22
22
|
if (entry.drafts) values.status ??= "draft";
|
|
23
23
|
values.id = customId(entry.id);
|
|
24
|
-
return mapConstraintErrors(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
return mapConstraintErrors(
|
|
25
|
+
() => withTransaction(async (db) => {
|
|
26
|
+
const [row] = await db.insert(table).values(values).returning();
|
|
27
|
+
await saveManyToMany(db, name, row.id, lists);
|
|
28
|
+
const [attached] = await attachManyToMany(db, name, entry, [
|
|
29
|
+
row
|
|
30
|
+
]);
|
|
31
|
+
return attached;
|
|
32
|
+
})
|
|
33
|
+
);
|
|
30
34
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createError, defineEventHandler, readValidatedBody } from "h3";
|
|
2
|
-
import {
|
|
2
|
+
import { withTransaction } from "#cms-db";
|
|
3
3
|
import { mapConstraintErrors } from "../utils/db-errors.js";
|
|
4
4
|
import { buildValidator, getRegistryEntry, idColumn, withUpdatedAt } from "../utils/registry.js";
|
|
5
5
|
import {
|
|
@@ -19,10 +19,14 @@ export default defineEventHandler(async (event) => {
|
|
|
19
19
|
const { values, lists } = splitRelationValues(entry, body);
|
|
20
20
|
await assertRelationTargets(entry, lists);
|
|
21
21
|
const set = withUpdatedAt(table, values);
|
|
22
|
-
return mapConstraintErrors(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
return mapConstraintErrors(
|
|
23
|
+
() => withTransaction(async (db) => {
|
|
24
|
+
const [row] = await db.insert(table).values({ id: entry.id, ...set }).onConflictDoUpdate({ target: idColumn(table), set }).returning();
|
|
25
|
+
await saveManyToMany(db, name, entry.id, lists);
|
|
26
|
+
const [attached] = await attachManyToMany(db, name, entry, [
|
|
27
|
+
row
|
|
28
|
+
]);
|
|
29
|
+
return attached;
|
|
30
|
+
})
|
|
31
|
+
);
|
|
28
32
|
});
|
|
@@ -11,8 +11,9 @@ export default defineEventHandler(async (event) => {
|
|
|
11
11
|
throw createError({ statusCode: 404, statusMessage: "Single objects have no items" });
|
|
12
12
|
}
|
|
13
13
|
const id = parseId(event);
|
|
14
|
-
const
|
|
14
|
+
const db = useDb();
|
|
15
|
+
const rows = await db.select().from(table).where(eq(idColumn(table), id)).limit(1);
|
|
15
16
|
if (!rows[0]) throw createError({ statusCode: 404, statusMessage: "Row not found" });
|
|
16
|
-
const [attached] = await attachManyToMany(name, entry, [rows[0]]);
|
|
17
|
+
const [attached] = await attachManyToMany(db, name, entry, [rows[0]]);
|
|
17
18
|
return attached;
|
|
18
19
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { eq } from "drizzle-orm";
|
|
2
2
|
import { createError, defineEventHandler, readValidatedBody } from "h3";
|
|
3
|
-
import {
|
|
3
|
+
import { withTransaction } from "#cms-db";
|
|
4
4
|
import {
|
|
5
5
|
buildValidator,
|
|
6
6
|
getRegistryEntry,
|
|
@@ -30,11 +30,15 @@ export default defineEventHandler(async (event) => {
|
|
|
30
30
|
const { values, lists } = splitRelationValues(entry, body);
|
|
31
31
|
await assertRelationTargets(entry, lists);
|
|
32
32
|
const set = withUpdatedAt(table, values);
|
|
33
|
-
return mapConstraintErrors(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
return mapConstraintErrors(
|
|
34
|
+
() => withTransaction(async (db) => {
|
|
35
|
+
const [row] = await db.update(table).set(set).where(eq(idColumn(table), id)).returning();
|
|
36
|
+
if (!row) throw createError({ statusCode: 404, statusMessage: "Row not found" });
|
|
37
|
+
await saveManyToMany(db, name, id, lists);
|
|
38
|
+
const [attached] = await attachManyToMany(db, name, entry, [
|
|
39
|
+
row
|
|
40
|
+
]);
|
|
41
|
+
return attached;
|
|
42
|
+
})
|
|
43
|
+
);
|
|
40
44
|
});
|
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { createError, defineEventHandler, readValidatedBody } from "h3";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { slugify } from "../../shared/index.js";
|
|
5
|
-
import { useMediaStorage } from "../utils/media.js";
|
|
5
|
+
import { assertUploadContentType, useMediaStorage } from "../utils/media.js";
|
|
6
6
|
import { requireAdmin } from "../utils/require-admin.js";
|
|
7
7
|
const MAX_BASE_LENGTH = 80;
|
|
8
8
|
const MAX_EXT_LENGTH = 10;
|
|
@@ -22,6 +22,7 @@ export default defineEventHandler(async (event) => {
|
|
|
22
22
|
await requireAdmin(event);
|
|
23
23
|
const { media, client, bucketUrl, publicUrl } = useMediaStorage(event);
|
|
24
24
|
const { filename, contentType, size } = await readValidatedBody(event, bodySchema.parse);
|
|
25
|
+
assertUploadContentType(contentType);
|
|
25
26
|
if (size > MAX_FILE_SIZE) {
|
|
26
27
|
throw createError({
|
|
27
28
|
statusCode: 413,
|
|
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { useDb } from "#cms-db";
|
|
4
4
|
import { cms_media } from "#cms-tables";
|
|
5
5
|
import { objectKeySchema } from "../../shared/validation.js";
|
|
6
|
-
import { toMediaItem, useMediaConfig } from "../utils/media.js";
|
|
6
|
+
import { assertUploadContentType, toMediaItem, useMediaConfig } from "../utils/media.js";
|
|
7
7
|
import { requireAdmin } from "../utils/require-admin.js";
|
|
8
8
|
const bodySchema = z.object({
|
|
9
9
|
key: objectKeySchema,
|
|
@@ -18,6 +18,7 @@ export default defineEventHandler(async (event) => {
|
|
|
18
18
|
await requireAdmin(event);
|
|
19
19
|
const { publicUrl } = useMediaConfig(event);
|
|
20
20
|
const body = await readValidatedBody(event, bodySchema.parse);
|
|
21
|
+
if (body.mime) assertUploadContentType(body.mime);
|
|
21
22
|
const values = {
|
|
22
23
|
key: body.key,
|
|
23
24
|
mime: body.mime ?? null,
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
|
3
|
+
import cmsConfig from "#cms-config";
|
|
3
4
|
import { useRuntimeConfig } from "#imports";
|
|
4
5
|
import { useDb } from "../utils/db-postgres.js";
|
|
5
6
|
export default async () => {
|
|
6
7
|
const { migrationsDir } = useRuntimeConfig().cms;
|
|
7
|
-
if (!existsSync(migrationsDir))
|
|
8
|
+
if (!existsSync(migrationsDir)) {
|
|
9
|
+
if (Object.keys(cmsConfig).length) {
|
|
10
|
+
console.error(
|
|
11
|
+
`[nuxt-cms] Migrations folder not found: ${migrationsDir}. CMS tables may be missing \u2014 run the dev server once and deploy the generated server/db/migrations folder.`
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
8
16
|
await migrate(useDb(), { migrationsFolder: migrationsDir });
|
|
9
17
|
};
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
3
|
+
import cmsConfig from "#cms-config";
|
|
3
4
|
import { useRuntimeConfig } from "#imports";
|
|
4
5
|
import { useDb } from "../utils/db-sqlite.js";
|
|
5
6
|
export default () => {
|
|
6
7
|
const { migrationsDir } = useRuntimeConfig().cms;
|
|
7
|
-
if (!existsSync(migrationsDir))
|
|
8
|
+
if (!existsSync(migrationsDir)) {
|
|
9
|
+
if (Object.keys(cmsConfig).length) {
|
|
10
|
+
console.error(
|
|
11
|
+
`[nuxt-cms] Migrations folder not found: ${migrationsDir}. CMS tables may be missing \u2014 run the dev server once and deploy the generated server/db/migrations folder.`
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
8
16
|
migrate(useDb(), { migrationsFolder: migrationsDir });
|
|
9
17
|
};
|
|
@@ -24,7 +24,7 @@ function safeEqual(a, b) {
|
|
|
24
24
|
return timingSafeEqual(hashA, hashB);
|
|
25
25
|
}
|
|
26
26
|
export default defineEventHandler(async (event) => {
|
|
27
|
-
const ip = getRequestIP(event) ?? "unknown";
|
|
27
|
+
const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown";
|
|
28
28
|
const now = Date.now();
|
|
29
29
|
pruneFailures(now);
|
|
30
30
|
if (globalFailures.resetAt > now && globalFailures.count >= RATE_GLOBAL_MAX_FAILURES) {
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export declare function useDb(): import("drizzle-orm/node-postgres").NodePgDatabase<Record<string, unknown>> & {
|
|
2
2
|
$client: import("pg").Pool;
|
|
3
3
|
};
|
|
4
|
+
type Db = ReturnType<typeof useDb>;
|
|
5
|
+
export type CmsDb = Db | Parameters<Parameters<Db['transaction']>[0]>[0];
|
|
6
|
+
export declare function withTransaction<T>(fn: (db: CmsDb) => Promise<T>): Promise<T>;
|
|
7
|
+
export {};
|
|
@@ -2,3 +2,5 @@ import Database from 'better-sqlite3';
|
|
|
2
2
|
export declare function useDb(): import("drizzle-orm/better-sqlite3").BetterSQLite3Database<Record<string, unknown>> & {
|
|
3
3
|
$client: Database.Database;
|
|
4
4
|
};
|
|
5
|
+
export type CmsDb = ReturnType<typeof useDb>;
|
|
6
|
+
export declare function withTransaction<T>(fn: (db: CmsDb) => Promise<T>): Promise<T>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdirSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import Database from "better-sqlite3";
|
|
4
|
+
import { sql } from "drizzle-orm";
|
|
4
5
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
5
6
|
import { useRuntimeConfig } from "#imports";
|
|
6
7
|
let _db = null;
|
|
@@ -15,3 +16,21 @@ export function useDb() {
|
|
|
15
16
|
}
|
|
16
17
|
return _db;
|
|
17
18
|
}
|
|
19
|
+
let txQueue = Promise.resolve();
|
|
20
|
+
export function withTransaction(fn) {
|
|
21
|
+
const db = useDb();
|
|
22
|
+
const run = txQueue.then(async () => {
|
|
23
|
+
await db.run(sql`begin immediate`);
|
|
24
|
+
try {
|
|
25
|
+
const result = await fn(db);
|
|
26
|
+
await db.run(sql`commit`);
|
|
27
|
+
return result;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
await db.run(sql`rollback`);
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
txQueue = run.catch(() => {
|
|
34
|
+
});
|
|
35
|
+
return run;
|
|
36
|
+
}
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
inArray,
|
|
9
9
|
isNotNull,
|
|
10
10
|
isNull,
|
|
11
|
-
like,
|
|
12
11
|
lt,
|
|
13
12
|
lte,
|
|
14
13
|
ne,
|
|
@@ -100,15 +99,22 @@ const OPERATORS = {
|
|
|
100
99
|
gte: (column, value) => gte(column, value),
|
|
101
100
|
lt: (column, value) => lt(column, value),
|
|
102
101
|
lte: (column, value) => lte(column, value),
|
|
103
|
-
like: (column, value) =>
|
|
102
|
+
like: (column, value) => {
|
|
103
|
+
const pattern = String(value).replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_").replaceAll("*", "%");
|
|
104
|
+
return sql`${column} like ${pattern} escape '\\'`;
|
|
105
|
+
},
|
|
104
106
|
in: (column, value) => inArray(column, value)
|
|
105
107
|
};
|
|
108
|
+
function columnFor(table, key) {
|
|
109
|
+
const columns = tableColumns(table);
|
|
110
|
+
return Object.hasOwn(columns, key) ? columns[key] : void 0;
|
|
111
|
+
}
|
|
106
112
|
function filterConditions(table, filters) {
|
|
107
113
|
if (!filters) return [];
|
|
108
114
|
const conditions = [];
|
|
109
115
|
for (const [key, ops] of Object.entries(filters)) {
|
|
110
116
|
if (!ops) continue;
|
|
111
|
-
const column =
|
|
117
|
+
const column = columnFor(table, key);
|
|
112
118
|
if (!column) throw new GraphQLError(`Unknown filter field: ${key}`);
|
|
113
119
|
for (const [op, value] of Object.entries(ops)) {
|
|
114
120
|
if (value === void 0) continue;
|
|
@@ -130,7 +136,7 @@ function filterConditions(table, filters) {
|
|
|
130
136
|
function sortOrder(table, sort) {
|
|
131
137
|
const order = [];
|
|
132
138
|
for (const part of sort ?? []) {
|
|
133
|
-
const column =
|
|
139
|
+
const column = columnFor(table, part.field);
|
|
134
140
|
if (!column) throw new GraphQLError(`Unknown sort field: ${part.field}`);
|
|
135
141
|
order.push(part.direction === "desc" ? desc(column) : asc(column));
|
|
136
142
|
}
|
|
@@ -9,6 +9,7 @@ interface MediaConfig {
|
|
|
9
9
|
accessKeyId: string;
|
|
10
10
|
secretAccessKey: string;
|
|
11
11
|
}
|
|
12
|
+
export declare function assertUploadContentType(contentType: string): void;
|
|
12
13
|
export declare function encodeKey(key: string): string;
|
|
13
14
|
export declare function useMediaConfig(event: H3Event): {
|
|
14
15
|
media: MediaConfig;
|
|
@@ -2,6 +2,33 @@ import { AwsClient } from "aws4fetch";
|
|
|
2
2
|
import { createError } from "h3";
|
|
3
3
|
import { useRuntimeConfig } from "#imports";
|
|
4
4
|
import { mediaPublicUrl, mediaTypeFor } from "../../shared/index.js";
|
|
5
|
+
const UPLOAD_TYPE_PREFIXES = ["image/", "video/", "audio/", "font/"];
|
|
6
|
+
const UPLOAD_TYPE_BLOCKLIST = /* @__PURE__ */ new Set(["image/svg+xml"]);
|
|
7
|
+
const UPLOAD_TYPES = /* @__PURE__ */ new Set([
|
|
8
|
+
"application/pdf",
|
|
9
|
+
"application/zip",
|
|
10
|
+
"application/gzip",
|
|
11
|
+
"application/json",
|
|
12
|
+
"text/plain",
|
|
13
|
+
"text/csv",
|
|
14
|
+
"text/markdown",
|
|
15
|
+
"application/msword",
|
|
16
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
17
|
+
"application/vnd.ms-excel",
|
|
18
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
19
|
+
"application/vnd.ms-powerpoint",
|
|
20
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
21
|
+
]);
|
|
22
|
+
export function assertUploadContentType(contentType) {
|
|
23
|
+
const type = contentType.toLowerCase();
|
|
24
|
+
const allowed = !UPLOAD_TYPE_BLOCKLIST.has(type) && (UPLOAD_TYPES.has(type) || UPLOAD_TYPE_PREFIXES.some((prefix) => type.startsWith(prefix)));
|
|
25
|
+
if (!allowed) {
|
|
26
|
+
throw createError({
|
|
27
|
+
statusCode: 415,
|
|
28
|
+
statusMessage: `Unsupported content type: ${contentType}`
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
5
32
|
export function encodeKey(key) {
|
|
6
33
|
return key.split("/").map(encodeURIComponent).join("/");
|
|
7
34
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CmsDb } from '#cms-db';
|
|
1
2
|
import type { CmsEntry } from '../../shared/index.js';
|
|
2
3
|
type Row = Record<string, unknown>;
|
|
3
4
|
export declare function splitRelationValues(entry: CmsEntry, body: Row): {
|
|
@@ -7,6 +8,6 @@ export declare function splitRelationValues(entry: CmsEntry, body: Row): {
|
|
|
7
8
|
lists: Record<string, string[]>;
|
|
8
9
|
};
|
|
9
10
|
export declare function assertRelationTargets(entry: CmsEntry, lists: Record<string, string[]>): Promise<void>;
|
|
10
|
-
export declare function saveManyToMany(name: string, sourceId: string, lists: Record<string, string[]>): Promise<void>;
|
|
11
|
-
export declare function attachManyToMany<T extends Row>(name: string, entry: CmsEntry, rows: T[]): Promise<T[]>;
|
|
11
|
+
export declare function saveManyToMany(db: CmsDb, name: string, sourceId: string, lists: Record<string, string[]>): Promise<void>;
|
|
12
|
+
export declare function attachManyToMany<T extends Row>(db: CmsDb, name: string, entry: CmsEntry, rows: T[]): Promise<T[]>;
|
|
12
13
|
export {};
|
|
@@ -48,8 +48,7 @@ export async function assertRelationTargets(entry, lists) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
-
export async function saveManyToMany(name, sourceId, lists) {
|
|
52
|
-
const db = useDb();
|
|
51
|
+
export async function saveManyToMany(db, name, sourceId, lists) {
|
|
53
52
|
for (const [key, ids] of Object.entries(lists)) {
|
|
54
53
|
const join = joinTable(name, key);
|
|
55
54
|
await db.delete(join.table).where(eq(join.sourceId, sourceId));
|
|
@@ -58,11 +57,10 @@ export async function saveManyToMany(name, sourceId, lists) {
|
|
|
58
57
|
}
|
|
59
58
|
}
|
|
60
59
|
}
|
|
61
|
-
export async function attachManyToMany(name, entry, rows) {
|
|
60
|
+
export async function attachManyToMany(db, name, entry, rows) {
|
|
62
61
|
const keys = manyToManyKeys(entry);
|
|
63
62
|
const ids = rows.map((row) => row.id).filter((id) => id != null);
|
|
64
63
|
if (!keys.length || !ids.length) return rows;
|
|
65
|
-
const db = useDb();
|
|
66
64
|
for (const key of keys) {
|
|
67
65
|
const join = joinTable(name, key);
|
|
68
66
|
const links = await db.select().from(join.table).where(inArray(join.sourceId, ids)).orderBy(asc(join.position));
|