@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.
Files changed (38) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/module.mjs +5 -1
  3. package/dist/runtime/app/components/cms/BlocksField.vue +59 -16
  4. package/dist/runtime/app/components/cms/ConfirmModal.d.vue.ts +20 -0
  5. package/dist/runtime/app/components/cms/ConfirmModal.vue +43 -0
  6. package/dist/runtime/app/components/cms/ConfirmModal.vue.d.ts +20 -0
  7. package/dist/runtime/app/components/cms/MediaGallery.vue +9 -2
  8. package/dist/runtime/app/components/cms/RelationField.vue +56 -10
  9. package/dist/runtime/app/components/cms/RichTextField.vue +63 -2
  10. package/dist/runtime/app/composables/cms-confirm.d.ts +4 -0
  11. package/dist/runtime/app/composables/cms-confirm.js +7 -0
  12. package/dist/runtime/app/i18n/en.json +32 -2
  13. package/dist/runtime/app/i18n/it.json +32 -2
  14. package/dist/runtime/app/pages/admin-collection.vue +89 -14
  15. package/dist/runtime/app/pages/admin-entry.vue +12 -5
  16. package/dist/runtime/server/api/collection.get.d.ts +1 -3
  17. package/dist/runtime/server/api/collection.get.js +34 -8
  18. package/dist/runtime/server/api/collection.post.js +11 -7
  19. package/dist/runtime/server/api/collection.put.js +11 -7
  20. package/dist/runtime/server/api/item.get.js +3 -2
  21. package/dist/runtime/server/api/item.put.js +12 -8
  22. package/dist/runtime/server/api/media-presign.post.js +2 -1
  23. package/dist/runtime/server/api/media.post.js +2 -1
  24. package/dist/runtime/server/plugins/migrate-postgres.js +9 -1
  25. package/dist/runtime/server/plugins/migrate-sqlite.js +9 -1
  26. package/dist/runtime/server/routes/auth/login.post.js +1 -1
  27. package/dist/runtime/server/utils/db-postgres.d.ts +4 -0
  28. package/dist/runtime/server/utils/db-postgres.js +3 -0
  29. package/dist/runtime/server/utils/db-sqlite.d.ts +2 -0
  30. package/dist/runtime/server/utils/db-sqlite.js +19 -0
  31. package/dist/runtime/server/utils/graphql.js +10 -4
  32. package/dist/runtime/server/utils/media.d.ts +1 -0
  33. package/dist/runtime/server/utils/media.js +27 -0
  34. package/dist/runtime/server/utils/relations.d.ts +3 -2
  35. package/dist/runtime/server/utils/relations.js +2 -4
  36. package/dist/runtime/server/utils/require-admin.js +15 -1
  37. package/dist/runtime/shared/index.d.ts +69 -1
  38. package/package.json +4 -1
@@ -32,7 +32,22 @@
32
32
  </div>
33
33
  </CmsPageHeader>
34
34
 
35
- <div v-if="config.kind === 'single'" class="cms-card p-7">
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 { data, refresh } = await useFetch(endpoint);
141
- const rows = computed(() => Array.isArray(data.value) ? data.value : []);
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 (error) {
233
+ } catch (error2) {
163
234
  toast.add({
164
235
  title: t("cms.toast.saveFailed"),
165
- description: errorMessage(error),
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 && !Array.isArray(data.value) ? pickFields(data.value) : emptyState();
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", rows.value.length)
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
- () => cmsApi(`${endpoint}/${row.id}`, {
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(row), status: next }
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 (!confirm(t("cms.collection.deleteConfirm"))) return;
271
- await submit(() => cmsApi(`${endpoint}/${row.id}`, { method: "DELETE" }));
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
- throw createError({ statusCode: 404, statusMessage: "Entry not found", fatal: true });
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
- onBeforeRouteLeave(() => {
121
- if (dirty.value && !confirm(t("cms.entry.unsavedChanges"))) return false;
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,6 +1,4 @@
1
1
  declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
2
2
  [x: string]: any;
3
- } | {
4
- [x: string]: any;
5
- }[] | null>>;
3
+ } | null>>;
6
4
  export default _default;
@@ -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 rows2 = await db.select().from(table).limit(1);
13
- await attachManyToMany(name, entry, rows2);
14
- return rows2[0] ?? null;
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 orderBy = tableColumns(table).createdAt ?? idColumn(table);
17
- const rows = await db.select().from(table).orderBy(desc(orderBy));
18
- return attachManyToMany(name, entry, rows);
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 { useDb } from "#cms-db";
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(async () => {
25
- const [row] = await useDb().insert(table).values(values).returning();
26
- await saveManyToMany(name, row.id, lists);
27
- const [attached] = await attachManyToMany(name, entry, [row]);
28
- return attached;
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 { useDb } from "#cms-db";
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(async () => {
23
- const [row] = await useDb().insert(table).values({ id: entry.id, ...set }).onConflictDoUpdate({ target: idColumn(table), set }).returning();
24
- await saveManyToMany(name, entry.id, lists);
25
- const [attached] = await attachManyToMany(name, entry, [row]);
26
- return attached;
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 rows = await useDb().select().from(table).where(eq(idColumn(table), id)).limit(1);
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 { useDb } from "#cms-db";
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(async () => {
34
- const [row] = await useDb().update(table).set(set).where(eq(idColumn(table), id)).returning();
35
- if (!row) throw createError({ statusCode: 404, statusMessage: "Row not found" });
36
- await saveManyToMany(name, id, lists);
37
- const [attached] = await attachManyToMany(name, entry, [row]);
38
- return attached;
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)) return;
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)) return;
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 {};
@@ -14,3 +14,6 @@ export function useDb() {
14
14
  }
15
15
  return _db;
16
16
  }
17
+ export function withTransaction(fn) {
18
+ return useDb().transaction((tx) => fn(tx));
19
+ }
@@ -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) => like(column, String(value).replaceAll("*", "%")),
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 = tableColumns(table)[key];
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 = tableColumns(table)[part.field];
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));