@xleddyl/nuxt-cms 0.1.61 → 0.2.1
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 +133 -14
- package/dist/runtime/app/components/cms/BlocksField.vue +86 -10
- package/dist/runtime/app/components/cms/BrandMark.d.vue.ts +6 -0
- package/dist/runtime/app/components/cms/BrandMark.vue +19 -0
- package/dist/runtime/app/components/cms/BrandMark.vue.d.ts +6 -0
- package/dist/runtime/app/components/cms/Checkbox.d.vue.ts +11 -0
- package/dist/runtime/app/components/cms/Checkbox.vue +21 -0
- package/dist/runtime/app/components/cms/Checkbox.vue.d.ts +11 -0
- package/dist/runtime/app/components/cms/Drawer.vue +5 -9
- package/dist/runtime/app/components/cms/EmptyState.vue +4 -2
- package/dist/runtime/app/components/cms/EntryDrawer.vue +12 -3
- package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +1 -0
- package/dist/runtime/app/components/cms/EntryForm.vue +104 -30
- package/dist/runtime/app/components/cms/EntryForm.vue.d.ts +1 -0
- package/dist/runtime/app/components/cms/FieldInput.vue +20 -5
- package/dist/runtime/app/components/cms/FormField.d.vue.ts +5 -3
- package/dist/runtime/app/components/cms/FormField.vue +8 -2
- package/dist/runtime/app/components/cms/FormField.vue.d.ts +5 -3
- package/dist/runtime/app/components/cms/Icon.vue +1 -1
- package/dist/runtime/app/components/cms/Input.d.vue.ts +0 -1
- package/dist/runtime/app/components/cms/Input.vue +7 -4
- package/dist/runtime/app/components/cms/Input.vue.d.ts +0 -1
- package/dist/runtime/app/components/cms/LocaleSwitch.vue +1 -1
- package/dist/runtime/app/components/cms/MediaFolderPicker.d.vue.ts +0 -1
- package/dist/runtime/app/components/cms/MediaFolderPicker.vue +1 -3
- package/dist/runtime/app/components/cms/MediaFolderPicker.vue.d.ts +0 -1
- package/dist/runtime/app/components/cms/MediaGallery.vue +24 -10
- package/dist/runtime/app/components/cms/MediaUpload.d.vue.ts +1 -0
- package/dist/runtime/app/components/cms/MediaUpload.vue +71 -41
- package/dist/runtime/app/components/cms/MediaUpload.vue.d.ts +1 -0
- package/dist/runtime/app/components/cms/Modal.vue +5 -9
- package/dist/runtime/app/components/cms/PageHeader.d.vue.ts +5 -3
- package/dist/runtime/app/components/cms/PageHeader.vue +16 -6
- package/dist/runtime/app/components/cms/PageHeader.vue.d.ts +5 -3
- package/dist/runtime/app/components/cms/PasswordInput.d.vue.ts +17 -0
- package/dist/runtime/app/components/cms/PasswordInput.vue +81 -0
- package/dist/runtime/app/components/cms/PasswordInput.vue.d.ts +17 -0
- package/dist/runtime/app/components/cms/RelationField.vue +7 -2
- package/dist/runtime/app/components/cms/SelectMenu.d.vue.ts +0 -1
- package/dist/runtime/app/components/cms/SelectMenu.vue +4 -11
- package/dist/runtime/app/components/cms/SelectMenu.vue.d.ts +0 -1
- package/dist/runtime/app/components/cms/SettingsAccount.d.vue.ts +3 -0
- package/dist/runtime/app/components/cms/SettingsAccount.vue +138 -0
- package/dist/runtime/app/components/cms/SettingsAccount.vue.d.ts +3 -0
- package/dist/runtime/app/components/cms/SettingsColumns.d.vue.ts +16 -0
- package/dist/runtime/app/components/cms/SettingsColumns.vue +102 -0
- package/dist/runtime/app/components/cms/SettingsColumns.vue.d.ts +16 -0
- package/dist/runtime/app/components/cms/SettingsEntry.d.vue.ts +6 -0
- package/dist/runtime/app/components/cms/SettingsEntry.vue +126 -0
- package/dist/runtime/app/components/cms/SettingsEntry.vue.d.ts +6 -0
- package/dist/runtime/app/components/cms/SettingsLayout.d.vue.ts +16 -0
- package/dist/runtime/app/components/cms/SettingsLayout.vue +177 -0
- package/dist/runtime/app/components/cms/SettingsLayout.vue.d.ts +16 -0
- package/dist/runtime/app/components/cms/SettingsModal.d.vue.ts +16 -0
- package/dist/runtime/app/components/cms/SettingsModal.vue +174 -0
- package/dist/runtime/app/components/cms/SettingsModal.vue.d.ts +16 -0
- package/dist/runtime/app/components/cms/SettingsUsers.d.vue.ts +3 -0
- package/dist/runtime/app/components/cms/SettingsUsers.vue +274 -0
- package/dist/runtime/app/components/cms/SettingsUsers.vue.d.ts +3 -0
- package/dist/runtime/app/components/cms/Spinner.vue +2 -2
- package/dist/runtime/app/components/cms/Table.d.vue.ts +6 -8
- package/dist/runtime/app/components/cms/Table.vue +42 -47
- package/dist/runtime/app/components/cms/Table.vue.d.ts +6 -8
- package/dist/runtime/app/components/cms/Textarea.d.vue.ts +0 -1
- package/dist/runtime/app/components/cms/Textarea.vue +1 -2
- package/dist/runtime/app/components/cms/Textarea.vue.d.ts +0 -1
- package/dist/runtime/app/components/cms/Toaster.vue +1 -1
- package/dist/runtime/app/components/cms/WelcomeModal.d.vue.ts +3 -0
- package/dist/runtime/app/components/cms/WelcomeModal.vue +69 -0
- package/dist/runtime/app/components/cms/WelcomeModal.vue.d.ts +3 -0
- package/dist/runtime/app/composables/cms-account.d.ts +11 -0
- package/dist/runtime/app/composables/cms-account.js +23 -0
- package/dist/runtime/app/composables/cms-entry-disabled.js +1 -1
- package/dist/runtime/app/composables/cms-media-keys.d.ts +1 -0
- package/dist/runtime/app/composables/cms-media-keys.js +5 -0
- package/dist/runtime/app/composables/cms-media-library.js +3 -0
- package/dist/runtime/app/composables/cms-media-uploader.d.ts +11 -2
- package/dist/runtime/app/composables/cms-media-uploader.js +139 -12
- package/dist/runtime/app/composables/cms-overlay.d.ts +4 -0
- package/dist/runtime/app/composables/cms-overlay.js +23 -0
- package/dist/runtime/app/composables/cms-settings.d.ts +12 -0
- package/dist/runtime/app/composables/cms-settings.js +59 -0
- package/dist/runtime/app/composables/cms-theme.d.ts +9 -0
- package/dist/runtime/app/composables/cms-theme.js +27 -0
- package/dist/runtime/app/layouts/cms-admin.d.vue.ts +2 -2
- package/dist/runtime/app/layouts/cms-admin.vue +71 -17
- package/dist/runtime/app/layouts/cms-admin.vue.d.ts +2 -2
- package/dist/runtime/app/pages/admin-collection.vue +127 -101
- package/dist/runtime/app/pages/admin-entry.vue +2 -0
- package/dist/runtime/app/pages/admin-login.vue +18 -3
- package/dist/runtime/app/pages/admin-media.vue +5 -1
- package/dist/runtime/app/utils/ui.d.ts +2 -0
- package/dist/runtime/app/utils/ui.js +18 -0
- package/dist/runtime/assets/main.css +1 -1
- package/dist/runtime/generate.js +1 -1
- package/dist/runtime/server/api/account-password.put.d.ts +4 -0
- package/dist/runtime/server/api/account-password.put.js +38 -0
- package/dist/runtime/server/api/account-welcome.post.d.ts +4 -0
- package/dist/runtime/server/api/account-welcome.post.js +14 -0
- package/dist/runtime/server/api/account.put.d.ts +3 -0
- package/dist/runtime/server/api/account.put.js +25 -0
- package/dist/runtime/server/api/collection.get.js +1 -1
- package/dist/runtime/server/api/media.delete.js +2 -1
- package/dist/runtime/server/api/media.post.js +2 -1
- package/dist/runtime/server/api/media.put.js +2 -1
- package/dist/runtime/server/api/settings.delete.d.ts +4 -0
- package/dist/runtime/server/api/settings.delete.js +12 -0
- package/dist/runtime/server/api/settings.get.d.ts +3 -0
- package/dist/runtime/server/api/settings.get.js +15 -0
- package/dist/runtime/server/api/settings.put.d.ts +3 -0
- package/dist/runtime/server/api/settings.put.js +14 -0
- package/dist/runtime/server/api/user.delete.d.ts +4 -0
- package/dist/runtime/server/api/user.delete.js +12 -0
- package/dist/runtime/server/api/user.put.d.ts +3 -0
- package/dist/runtime/server/api/user.put.js +27 -0
- package/dist/runtime/server/api/users.get.d.ts +3 -0
- package/dist/runtime/server/api/users.get.js +11 -0
- package/dist/runtime/server/api/users.post.d.ts +3 -0
- package/dist/runtime/server/api/users.post.js +36 -0
- package/dist/runtime/server/plugins/session-validate.d.ts +2 -0
- package/dist/runtime/server/plugins/session-validate.js +13 -0
- package/dist/runtime/server/routes/auth/login.post.js +20 -10
- package/dist/runtime/server/utils/graphql-depth.js +12 -5
- package/dist/runtime/server/utils/graphql.js +2 -2
- package/dist/runtime/server/utils/media-folders.js +10 -2
- package/dist/runtime/server/utils/media.d.ts +1 -0
- package/dist/runtime/server/utils/media.js +4 -1
- package/dist/runtime/server/utils/migrate.js +1 -1
- package/dist/runtime/server/utils/password.d.ts +3 -0
- package/dist/runtime/server/utils/password.js +31 -0
- package/dist/runtime/server/utils/require-admin.d.ts +4 -2
- package/dist/runtime/server/utils/require-admin.js +32 -28
- package/dist/runtime/server/utils/same-origin.d.ts +2 -0
- package/dist/runtime/server/utils/same-origin.js +24 -0
- package/dist/runtime/server/utils/settings.d.ts +16 -0
- package/dist/runtime/server/utils/settings.js +31 -0
- package/dist/runtime/server/utils/users.d.ts +15 -0
- package/dist/runtime/server/utils/users.js +34 -0
- package/dist/runtime/shared/index.d.ts +13 -1
- package/dist/runtime/shared/index.js +2 -0
- package/dist/runtime/shared/layout.d.ts +33 -0
- package/dist/runtime/shared/layout.js +127 -0
- package/dist/runtime/shared/users.d.ts +18 -0
- package/dist/runtime/shared/users.js +1 -0
- package/dist/runtime/shared/validation.js +5 -4
- package/package.json +3 -3
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createError, defineEventHandler, readValidatedBody } from "h3";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { useDb } from "#cms-db";
|
|
4
|
+
import { cms_users } from "#cms-tables";
|
|
5
|
+
import { MIN_PASSWORD_LENGTH } from "../../shared/index.js";
|
|
6
|
+
import { customId } from "../utils/custom-id.js";
|
|
7
|
+
import { mapConstraintErrors } from "../utils/db-errors.js";
|
|
8
|
+
import { hashPassword } from "../utils/password.js";
|
|
9
|
+
import { isSuperAdminEmail, requireSuperAdmin } from "../utils/require-admin.js";
|
|
10
|
+
import { findUserByEmail, toAccount } from "../utils/users.js";
|
|
11
|
+
const bodySchema = z.object({
|
|
12
|
+
email: z.string().trim().toLowerCase().email().max(254),
|
|
13
|
+
name: z.string().trim().max(120).nullish(),
|
|
14
|
+
password: z.string().min(MIN_PASSWORD_LENGTH).max(256)
|
|
15
|
+
});
|
|
16
|
+
export default defineEventHandler(async (event) => {
|
|
17
|
+
await requireSuperAdmin(event);
|
|
18
|
+
const body = await readValidatedBody(event, bodySchema.parse);
|
|
19
|
+
if (isSuperAdminEmail(event, body.email) || await findUserByEmail(body.email)) {
|
|
20
|
+
throw createError({ statusCode: 409, statusMessage: "A user with this email already exists" });
|
|
21
|
+
}
|
|
22
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
23
|
+
const passwordHash = await hashPassword(body.password);
|
|
24
|
+
const [row] = await mapConstraintErrors(
|
|
25
|
+
() => useDb().insert(cms_users).values({
|
|
26
|
+
id: customId("usr"),
|
|
27
|
+
email: body.email,
|
|
28
|
+
name: body.name || null,
|
|
29
|
+
role: "admin",
|
|
30
|
+
passwordHash,
|
|
31
|
+
createdAt: now,
|
|
32
|
+
updatedAt: now
|
|
33
|
+
}).returning()
|
|
34
|
+
);
|
|
35
|
+
return toAccount(row);
|
|
36
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createError } from "h3";
|
|
2
|
+
import { clearUserSession, sessionHooks } from "#imports";
|
|
3
|
+
import { findUserById, passwordStamp } from "../utils/users.js";
|
|
4
|
+
export default () => {
|
|
5
|
+
sessionHooks.hook("fetch", async (session, event) => {
|
|
6
|
+
const user = session.user;
|
|
7
|
+
if (!user?.id) return;
|
|
8
|
+
const row = await findUserById(user.id);
|
|
9
|
+
if (row && user.passwordStamp === passwordStamp(row.passwordHash)) return;
|
|
10
|
+
await clearUserSession(event);
|
|
11
|
+
throw createError({ statusCode: 401, statusMessage: "Session expired" });
|
|
12
|
+
});
|
|
13
|
+
};
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { createError, defineEventHandler, getRequestIP, readValidatedBody } from "h3";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { replaceUserSession, useRuntimeConfig, useStorage } from "#imports";
|
|
5
|
+
import { eq } from "drizzle-orm";
|
|
6
|
+
import { useDb } from "#cms-db";
|
|
7
|
+
import { cms_users } from "#cms-tables";
|
|
8
|
+
import { verifyNothing, verifyPassword } from "../../utils/password.js";
|
|
9
|
+
import { assertSameOrigin } from "../../utils/same-origin.js";
|
|
10
|
+
import { findUserByEmail, sessionUserFor } from "../../utils/users.js";
|
|
6
11
|
const credentialsSchema = z.object({
|
|
7
|
-
email: z.string().trim().min(1),
|
|
8
|
-
password: z.string().min(1)
|
|
12
|
+
email: z.string().trim().min(1).max(254),
|
|
13
|
+
password: z.string().min(1).max(256)
|
|
9
14
|
});
|
|
10
15
|
const RATE_WINDOW_MS = 15 * 6e4;
|
|
11
16
|
const RATE_MAX_FAILURES = 10;
|
|
@@ -62,17 +67,22 @@ export default defineEventHandler(async (event) => {
|
|
|
62
67
|
});
|
|
63
68
|
}
|
|
64
69
|
const body = await readValidatedBody(event, credentialsSchema.parse);
|
|
70
|
+
const email = body.email.toLowerCase();
|
|
65
71
|
const { adminEmail, adminPassword } = useRuntimeConfig(event).cms;
|
|
66
|
-
|
|
67
|
-
|
|
72
|
+
const superAdmin = !!adminEmail && !!adminPassword && safeEqual(email, adminEmail.toLowerCase()) && safeEqual(body.password, adminPassword);
|
|
73
|
+
if (superAdmin) {
|
|
74
|
+
await rateStorage().removeItem(ip);
|
|
75
|
+
await replaceUserSession(event, { user: { email, role: "superadmin" } });
|
|
76
|
+
return { loggedIn: true };
|
|
68
77
|
}
|
|
69
|
-
const
|
|
70
|
-
const passwordOk =
|
|
71
|
-
if (!
|
|
78
|
+
const row = await findUserByEmail(email);
|
|
79
|
+
const passwordOk = row ? await verifyPassword(row.passwordHash, body.password) : await verifyNothing(body.password);
|
|
80
|
+
if (!row || !passwordOk) {
|
|
72
81
|
await Promise.all([recordFailure(ip, now), recordFailure(GLOBAL_KEY, now), prune(now)]);
|
|
73
82
|
throw createError({ statusCode: 401, statusMessage: "Invalid credentials" });
|
|
74
83
|
}
|
|
75
84
|
await rateStorage().removeItem(ip);
|
|
76
|
-
await
|
|
85
|
+
await useDb().update(cms_users).set({ lastLoginAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(cms_users.id, row.id));
|
|
86
|
+
await replaceUserSession(event, { user: sessionUserFor(row) });
|
|
77
87
|
return { loggedIn: true };
|
|
78
88
|
});
|
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
import { GraphQLError, Kind } from "graphql";
|
|
2
2
|
const DEFAULT_MAX_DEPTH = 8;
|
|
3
3
|
const DEFAULT_MAX_FIELDS = 500;
|
|
4
|
-
function measure(selectionSet, fragments, visited) {
|
|
4
|
+
function measure(selectionSet, fragments, visited, fragmentDepths) {
|
|
5
5
|
let max = 0;
|
|
6
6
|
for (const selection of selectionSet.selections) {
|
|
7
7
|
if (selection.kind === Kind.FIELD) {
|
|
8
8
|
if (selection.name.value.startsWith("__")) continue;
|
|
9
|
-
const depth = selection.selectionSet ? 1 + measure(selection.selectionSet, fragments, visited) : 1;
|
|
9
|
+
const depth = selection.selectionSet ? 1 + measure(selection.selectionSet, fragments, visited, fragmentDepths) : 1;
|
|
10
10
|
max = Math.max(max, depth);
|
|
11
11
|
} else if (selection.kind === Kind.FRAGMENT_SPREAD) {
|
|
12
12
|
const name = selection.name.value;
|
|
13
13
|
if (visited.has(name)) continue;
|
|
14
|
+
const known = fragmentDepths.get(name);
|
|
15
|
+
if (known !== void 0) {
|
|
16
|
+
max = Math.max(max, known);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
14
19
|
const fragment = fragments.get(name);
|
|
15
20
|
if (fragment) {
|
|
16
21
|
visited.add(name);
|
|
17
|
-
|
|
22
|
+
const depth = measure(fragment.selectionSet, fragments, visited, fragmentDepths);
|
|
18
23
|
visited.delete(name);
|
|
24
|
+
fragmentDepths.set(name, depth);
|
|
25
|
+
max = Math.max(max, depth);
|
|
19
26
|
}
|
|
20
27
|
} else if (selection.kind === Kind.INLINE_FRAGMENT) {
|
|
21
|
-
max = Math.max(max, measure(selection.selectionSet, fragments, visited));
|
|
28
|
+
max = Math.max(max, measure(selection.selectionSet, fragments, visited, fragmentDepths));
|
|
22
29
|
}
|
|
23
30
|
}
|
|
24
31
|
return max;
|
|
@@ -32,7 +39,7 @@ export function createDepthRule(maxDepth) {
|
|
|
32
39
|
if (definition.kind === Kind.FRAGMENT_DEFINITION)
|
|
33
40
|
fragments.set(definition.name.value, definition);
|
|
34
41
|
}
|
|
35
|
-
const depth = measure(node.selectionSet, fragments, /* @__PURE__ */ new Set());
|
|
42
|
+
const depth = measure(node.selectionSet, fragments, /* @__PURE__ */ new Set(), /* @__PURE__ */ new Map());
|
|
36
43
|
if (depth > limit) {
|
|
37
44
|
context.reportError(
|
|
38
45
|
new GraphQLError(`Query is too deep: depth ${depth} exceeds the maximum of ${limit}`)
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import { GraphQLError, GraphQLScalarType, valueFromASTUntyped } from "graphql";
|
|
17
17
|
import { createSchema } from "graphql-yoga";
|
|
18
18
|
import cmsConfig from "#cms-config";
|
|
19
|
-
import { useDb } from "#cms-db";
|
|
19
|
+
import { cmsDialect, useDb } from "#cms-db";
|
|
20
20
|
import * as cmsTables from "#cms-tables";
|
|
21
21
|
import { useRuntimeConfig } from "#imports";
|
|
22
22
|
import {
|
|
@@ -129,7 +129,7 @@ const OPERATORS = {
|
|
|
129
129
|
lte: (column, value) => lte(column, value),
|
|
130
130
|
like: (column, value) => {
|
|
131
131
|
const pattern = String(value).replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_").replaceAll("*", "%");
|
|
132
|
-
return sql`${column} like ${pattern} escape '\\'`;
|
|
132
|
+
return cmsDialect === "postgres" ? sql`cast(${column} as text) ilike ${pattern} escape '\\'` : sql`${column} like ${pattern} escape '\\'`;
|
|
133
133
|
},
|
|
134
134
|
in: (column, value) => inArray(column, value)
|
|
135
135
|
};
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { eq, inArray, or, sql } from "drizzle-orm";
|
|
2
2
|
import { useDb } from "#cms-db";
|
|
3
3
|
import { cms_media } from "#cms-tables";
|
|
4
4
|
import { mediaFolderMarkerKey } from "../../shared/index.js";
|
|
5
5
|
const KEY_CHUNK = 90;
|
|
6
|
+
function escapeLike(value) {
|
|
7
|
+
return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
|
8
|
+
}
|
|
6
9
|
export async function writeMediaFolderMarker(store, folder) {
|
|
7
10
|
const key = mediaFolderMarkerKey(folder);
|
|
8
11
|
await store.write(key, new Uint8Array(), "application/x-empty");
|
|
@@ -18,7 +21,12 @@ export async function writeMediaFolderMarker(store, folder) {
|
|
|
18
21
|
await useDb().insert(cms_media).values(values).onConflictDoUpdate({ target: cms_media.key, set: values });
|
|
19
22
|
}
|
|
20
23
|
export function mediaRowsInFolder(folder) {
|
|
21
|
-
return useDb().select({ key: cms_media.key, folder: cms_media.folder }).from(cms_media).where(
|
|
24
|
+
return useDb().select({ key: cms_media.key, folder: cms_media.folder }).from(cms_media).where(
|
|
25
|
+
or(
|
|
26
|
+
sql`${cms_media.folder} like ${`${escapeLike(folder)}/%`} escape '\\'`,
|
|
27
|
+
eq(cms_media.folder, folder)
|
|
28
|
+
)
|
|
29
|
+
);
|
|
22
30
|
}
|
|
23
31
|
export async function deleteMediaRows(store, keys) {
|
|
24
32
|
for (const key of keys) {
|
|
@@ -20,6 +20,7 @@ export interface MediaStore {
|
|
|
20
20
|
write: (key: string, body: Uint8Array<ArrayBuffer>, contentType: string) => Promise<void>;
|
|
21
21
|
remove: (key: string) => Promise<void>;
|
|
22
22
|
}
|
|
23
|
+
export declare function baseContentType(contentType: string): string;
|
|
23
24
|
export declare function isAllowedUploadType(contentType: string): boolean;
|
|
24
25
|
export declare function assertUploadContentType(contentType: string): void;
|
|
25
26
|
export declare function assertUploadSize(size: number, maxFileSize: number): void;
|
|
@@ -20,8 +20,11 @@ const UPLOAD_TYPES = /* @__PURE__ */ new Set([
|
|
|
20
20
|
"application/vnd.ms-powerpoint",
|
|
21
21
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
22
22
|
]);
|
|
23
|
+
export function baseContentType(contentType) {
|
|
24
|
+
return contentType.split(";")[0].trim().toLowerCase();
|
|
25
|
+
}
|
|
23
26
|
export function isAllowedUploadType(contentType) {
|
|
24
|
-
const type = contentType
|
|
27
|
+
const type = baseContentType(contentType);
|
|
25
28
|
return !UPLOAD_TYPE_BLOCKLIST.has(type) && (UPLOAD_TYPES.has(type) || UPLOAD_TYPE_PREFIXES.some((prefix) => type.startsWith(prefix)));
|
|
26
29
|
}
|
|
27
30
|
export function assertUploadContentType(contentType) {
|
|
@@ -18,7 +18,7 @@ async function pendingMigrations() {
|
|
|
18
18
|
const migrations = await readMigrations(migrationsDir);
|
|
19
19
|
if (!migrations.length && Object.keys(cmsConfig).length) {
|
|
20
20
|
console.error(
|
|
21
|
-
`[nuxt-cms] No migrations were found for this build (expected in ${migrationsDir}). CMS tables may be missing
|
|
21
|
+
`[nuxt-cms] No migrations were found for this build (expected in ${migrationsDir}). CMS tables may be missing. Run the dev server once to generate them, commit server/db/migrations, and rebuild.`
|
|
22
22
|
);
|
|
23
23
|
}
|
|
24
24
|
return migrations;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
|
2
|
+
const KEY_LENGTH = 64;
|
|
3
|
+
const COST = 16384;
|
|
4
|
+
function derive(password, salt, cost) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
scrypt(
|
|
7
|
+
password,
|
|
8
|
+
salt,
|
|
9
|
+
KEY_LENGTH,
|
|
10
|
+
{ N: cost, r: 8, p: 1 },
|
|
11
|
+
(error, key) => error ? reject(error) : resolve(key)
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export async function hashPassword(password) {
|
|
16
|
+
const salt = randomBytes(16);
|
|
17
|
+
const key = await derive(password, salt, COST);
|
|
18
|
+
return `scrypt$${COST}$${salt.toString("base64")}$${key.toString("base64")}`;
|
|
19
|
+
}
|
|
20
|
+
export async function verifyPassword(stored, password) {
|
|
21
|
+
const [scheme, cost, salt, key] = stored.split("$");
|
|
22
|
+
if (scheme !== "scrypt" || !cost || !salt || !key) return false;
|
|
23
|
+
const expected = Buffer.from(key, "base64");
|
|
24
|
+
const actual = await derive(password, Buffer.from(salt, "base64"), Number(cost));
|
|
25
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
26
|
+
}
|
|
27
|
+
const DUMMY_HASH = "scrypt$16384$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
28
|
+
export async function verifyNothing(password) {
|
|
29
|
+
await verifyPassword(DUMMY_HASH, password);
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import type { H3Event } from 'h3';
|
|
2
|
-
|
|
3
|
-
export declare function
|
|
2
|
+
import type { CmsSessionUser } from '../../shared/index.js';
|
|
3
|
+
export declare function isSuperAdminEmail(event: H3Event, email: string): boolean;
|
|
4
|
+
export declare function requireAdmin(event: H3Event): Promise<CmsSessionUser>;
|
|
5
|
+
export declare function requireSuperAdmin(event: H3Event): Promise<CmsSessionUser>;
|
|
@@ -1,37 +1,41 @@
|
|
|
1
|
-
import { createError
|
|
1
|
+
import { createError } from "h3";
|
|
2
2
|
import { getUserSession, useRuntimeConfig } from "#imports";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return url.protocol === "http:" || url.protocol === "https:" ? url.host : void 0;
|
|
9
|
-
} catch {
|
|
10
|
-
return void 0;
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
function rejectCrossOrigin() {
|
|
14
|
-
throw createError({ statusCode: 403, statusMessage: "Cross-origin request rejected" });
|
|
15
|
-
}
|
|
16
|
-
export function assertSameOrigin(event) {
|
|
17
|
-
const expectedHost = getRequestHost(event);
|
|
18
|
-
const origin = getRequestHeader(event, "origin");
|
|
19
|
-
if (origin) {
|
|
20
|
-
if (hostOfUrl(origin) !== expectedHost) rejectCrossOrigin();
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
if (ORIGINLESS_SAFE_METHODS.has(event.method)) return;
|
|
24
|
-
if (hostOfUrl(getRequestHeader(event, "referer")) !== expectedHost) rejectCrossOrigin();
|
|
3
|
+
import { assertSameOrigin } from "./same-origin.js";
|
|
4
|
+
import { findUserById, passwordStamp } from "./users.js";
|
|
5
|
+
export function isSuperAdminEmail(event, email) {
|
|
6
|
+
const { adminEmail } = useRuntimeConfig(event).cms;
|
|
7
|
+
return !!adminEmail && email.toLowerCase() === adminEmail.toLowerCase();
|
|
25
8
|
}
|
|
26
9
|
export async function requireAdmin(event) {
|
|
27
10
|
assertSameOrigin(event);
|
|
28
11
|
const session = await getUserSession(event);
|
|
29
|
-
const
|
|
30
|
-
if (!email) {
|
|
12
|
+
const user = session.user;
|
|
13
|
+
if (!user?.email) {
|
|
31
14
|
throw createError({ statusCode: 401, statusMessage: "Authentication required" });
|
|
32
15
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
16
|
+
if (!user.id) {
|
|
17
|
+
if (!isSuperAdminEmail(event, user.email)) {
|
|
18
|
+
throw createError({ statusCode: 403, statusMessage: "Not authorized" });
|
|
19
|
+
}
|
|
20
|
+
return { email: user.email.toLowerCase(), name: null, role: "superadmin" };
|
|
21
|
+
}
|
|
22
|
+
const row = await findUserById(user.id);
|
|
23
|
+
if (!row || user.passwordStamp !== passwordStamp(row.passwordHash)) {
|
|
24
|
+
throw createError({ statusCode: 401, statusMessage: "Authentication required" });
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
id: row.id,
|
|
28
|
+
email: row.email,
|
|
29
|
+
name: row.name ?? null,
|
|
30
|
+
role: "admin",
|
|
31
|
+
firstLogin: !!row.mustChangePassword,
|
|
32
|
+
passwordStamp: user.passwordStamp
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export async function requireSuperAdmin(event) {
|
|
36
|
+
const user = await requireAdmin(event);
|
|
37
|
+
if (user.role !== "superadmin") {
|
|
38
|
+
throw createError({ statusCode: 403, statusMessage: "Only the super admin can manage users" });
|
|
36
39
|
}
|
|
40
|
+
return user;
|
|
37
41
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createError, getRequestHeader, getRequestHost } from "h3";
|
|
2
|
+
const ORIGINLESS_SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
3
|
+
function hostOfUrl(value) {
|
|
4
|
+
if (!value) return void 0;
|
|
5
|
+
try {
|
|
6
|
+
const url = new URL(value);
|
|
7
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.host : void 0;
|
|
8
|
+
} catch {
|
|
9
|
+
return void 0;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function rejectCrossOrigin() {
|
|
13
|
+
throw createError({ statusCode: 403, statusMessage: "Cross-origin request rejected" });
|
|
14
|
+
}
|
|
15
|
+
export function assertSameOrigin(event) {
|
|
16
|
+
const expectedHost = getRequestHost(event);
|
|
17
|
+
const origin = getRequestHeader(event, "origin");
|
|
18
|
+
if (origin) {
|
|
19
|
+
if (hostOfUrl(origin) !== expectedHost) rejectCrossOrigin();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (ORIGINLESS_SAFE_METHODS.has(event.method)) return;
|
|
23
|
+
if (hostOfUrl(getRequestHeader(event, "referer")) !== expectedHost) rejectCrossOrigin();
|
|
24
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { H3Event } from 'h3';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { CmsEntrySettings } from '../../shared/index.js';
|
|
4
|
+
export declare const entrySettingsSchema: z.ZodObject<{
|
|
5
|
+
columns: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
6
|
+
layout: z.ZodOptional<z.ZodObject<{
|
|
7
|
+
main: z.ZodArray<z.ZodObject<{
|
|
8
|
+
title: z.ZodOptional<z.ZodString>;
|
|
9
|
+
description: z.ZodOptional<z.ZodString>;
|
|
10
|
+
collapsed: z.ZodOptional<z.ZodBoolean>;
|
|
11
|
+
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
|
12
|
+
}, z.core.$strip>>;
|
|
13
|
+
}, z.core.$strip>>;
|
|
14
|
+
}, z.core.$strict>;
|
|
15
|
+
export declare function requireSettingsKey(event: H3Event): string;
|
|
16
|
+
export declare function parseSettingsValue(raw: string): CmsEntrySettings | null;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createError, getRouterParam } from "h3";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import cmsConfig from "#cms-config";
|
|
4
|
+
const fieldKey = z.string().min(1).max(128);
|
|
5
|
+
const sectionSchema = z.object({
|
|
6
|
+
title: z.string().max(200).optional(),
|
|
7
|
+
description: z.string().max(1e3).optional(),
|
|
8
|
+
collapsed: z.boolean().optional(),
|
|
9
|
+
rows: z.array(z.array(fieldKey).max(4)).max(500)
|
|
10
|
+
});
|
|
11
|
+
export const entrySettingsSchema = z.object({
|
|
12
|
+
columns: z.array(fieldKey).max(200).optional(),
|
|
13
|
+
layout: z.object({
|
|
14
|
+
main: z.array(sectionSchema).max(100)
|
|
15
|
+
}).optional()
|
|
16
|
+
}).strict();
|
|
17
|
+
export function requireSettingsKey(event) {
|
|
18
|
+
const key = getRouterParam(event, "key") ?? "";
|
|
19
|
+
if (!Object.hasOwn(cmsConfig, key)) {
|
|
20
|
+
throw createError({ statusCode: 404, statusMessage: `Unknown entry: ${key}` });
|
|
21
|
+
}
|
|
22
|
+
return key;
|
|
23
|
+
}
|
|
24
|
+
export function parseSettingsValue(raw) {
|
|
25
|
+
try {
|
|
26
|
+
const parsed = entrySettingsSchema.safeParse(JSON.parse(raw));
|
|
27
|
+
return parsed.success ? parsed.data : null;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { cms_users } from '#cms-tables';
|
|
2
|
+
import type { CmsAccount } from '../../shared/index.js';
|
|
3
|
+
export type CmsUserRow = typeof cms_users.$inferSelect;
|
|
4
|
+
export declare function findUserByEmail(email: string): Promise<CmsUserRow | undefined>;
|
|
5
|
+
export declare function findUserById(id: string): Promise<CmsUserRow | undefined>;
|
|
6
|
+
export declare function passwordStamp(passwordHash: string): string;
|
|
7
|
+
export declare function sessionUserFor(row: CmsUserRow): {
|
|
8
|
+
id: string;
|
|
9
|
+
email: string;
|
|
10
|
+
name: string | null;
|
|
11
|
+
role: "admin";
|
|
12
|
+
firstLogin: boolean;
|
|
13
|
+
passwordStamp: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function toAccount(row: CmsUserRow): CmsAccount;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import { useDb } from "#cms-db";
|
|
3
|
+
import { cms_users } from "#cms-tables";
|
|
4
|
+
export async function findUserByEmail(email) {
|
|
5
|
+
const [row] = await useDb().select().from(cms_users).where(eq(cms_users.email, email.toLowerCase()));
|
|
6
|
+
return row;
|
|
7
|
+
}
|
|
8
|
+
export async function findUserById(id) {
|
|
9
|
+
const [row] = await useDb().select().from(cms_users).where(eq(cms_users.id, id));
|
|
10
|
+
return row;
|
|
11
|
+
}
|
|
12
|
+
export function passwordStamp(passwordHash) {
|
|
13
|
+
return passwordHash.slice(-16);
|
|
14
|
+
}
|
|
15
|
+
export function sessionUserFor(row) {
|
|
16
|
+
return {
|
|
17
|
+
id: row.id,
|
|
18
|
+
email: row.email,
|
|
19
|
+
name: row.name ?? null,
|
|
20
|
+
role: "admin",
|
|
21
|
+
firstLogin: !!row.mustChangePassword,
|
|
22
|
+
passwordStamp: passwordStamp(row.passwordHash)
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function toAccount(row) {
|
|
26
|
+
return {
|
|
27
|
+
id: row.id,
|
|
28
|
+
email: row.email,
|
|
29
|
+
name: row.name ?? null,
|
|
30
|
+
role: row.role,
|
|
31
|
+
lastLoginAt: row.lastLoginAt ?? null,
|
|
32
|
+
createdAt: String(row.createdAt)
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PgTable } from 'drizzle-orm/pg-core';
|
|
2
2
|
import type { SQLiteTable } from 'drizzle-orm/sqlite-core';
|
|
3
|
+
import type { CmsLayoutBlock, CmsListInput } from './layout.js';
|
|
3
4
|
export type CmsTable = SQLiteTable | PgTable;
|
|
4
5
|
export type FieldType = 'text' | 'richtext' | 'number' | 'boolean' | 'date' | 'email' | 'slug' | 'select' | 'json' | 'media' | 'relation' | 'blocks';
|
|
5
6
|
export interface CmsI18n {
|
|
@@ -76,6 +77,8 @@ export interface CmsTab {
|
|
|
76
77
|
export interface FieldConfig {
|
|
77
78
|
label: string;
|
|
78
79
|
type: FieldType;
|
|
80
|
+
description?: string;
|
|
81
|
+
placeholder?: string;
|
|
79
82
|
tab?: string;
|
|
80
83
|
required?: boolean;
|
|
81
84
|
private?: boolean;
|
|
@@ -138,6 +141,9 @@ export interface CmsEntry {
|
|
|
138
141
|
columns?: string[];
|
|
139
142
|
tabs?: CmsTab[];
|
|
140
143
|
table?: CmsTable;
|
|
144
|
+
icon?: string;
|
|
145
|
+
layout?: CmsLayoutBlock[];
|
|
146
|
+
list?: CmsListInput;
|
|
141
147
|
}
|
|
142
148
|
export declare const MOBILE_MEDIA_SUFFIX = "Mobile";
|
|
143
149
|
export declare function mobileMediaKey(key: string): string;
|
|
@@ -168,6 +174,8 @@ export declare function blocksFieldTypeName(entryName: string, fieldKey: string)
|
|
|
168
174
|
export declare function blockTypeName(entryName: string, fieldKey: string, blockName: string): string;
|
|
169
175
|
interface FieldInputBase {
|
|
170
176
|
label: string;
|
|
177
|
+
description?: string;
|
|
178
|
+
placeholder?: string;
|
|
171
179
|
required?: boolean;
|
|
172
180
|
private?: boolean;
|
|
173
181
|
tab?: string;
|
|
@@ -234,6 +242,9 @@ export type CmsFieldInput = TextFieldInput | RichtextFieldInput | NumberFieldInp
|
|
|
234
242
|
interface CmsEntryInputBase {
|
|
235
243
|
id: string;
|
|
236
244
|
label: string;
|
|
245
|
+
icon?: string;
|
|
246
|
+
layout?: CmsLayoutBlock[];
|
|
247
|
+
list?: CmsListInput;
|
|
237
248
|
drafts?: boolean;
|
|
238
249
|
tabs?: CmsTab[];
|
|
239
250
|
fields: Record<string, CmsFieldInput>;
|
|
@@ -261,4 +272,5 @@ export interface CmsPageInput extends CmsEntryInputBase {
|
|
|
261
272
|
export type CmsEntryInput = CmsCollectionInput | CmsSingleInput | CmsPageInput;
|
|
262
273
|
export type CmsConfigInput = Record<string, CmsEntryInput>;
|
|
263
274
|
export declare function defineCmsConfig<T extends CmsConfigInput>(config: T): T;
|
|
264
|
-
export
|
|
275
|
+
export * from './layout.js';
|
|
276
|
+
export * from './users.js';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { FieldConfig } from './index.js';
|
|
2
|
+
export declare const MAX_LAYOUT_ROW_FIELDS = 4;
|
|
3
|
+
export type CmsLayoutRow = string | string[];
|
|
4
|
+
export interface CmsLayoutSectionInput {
|
|
5
|
+
title: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
collapsed?: boolean;
|
|
8
|
+
rows: CmsLayoutRow[];
|
|
9
|
+
}
|
|
10
|
+
export type CmsLayoutBlock = CmsLayoutRow | CmsLayoutSectionInput;
|
|
11
|
+
export interface CmsLayoutSection {
|
|
12
|
+
title?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
collapsed?: boolean;
|
|
15
|
+
rows: string[][];
|
|
16
|
+
}
|
|
17
|
+
export interface CmsFormLayout {
|
|
18
|
+
main: CmsLayoutSection[];
|
|
19
|
+
}
|
|
20
|
+
export interface CmsListInput {
|
|
21
|
+
columns?: string[];
|
|
22
|
+
}
|
|
23
|
+
export interface CmsEntrySettings {
|
|
24
|
+
columns?: string[];
|
|
25
|
+
layout?: CmsFormLayout;
|
|
26
|
+
}
|
|
27
|
+
export type CmsSettingsMap = Record<string, CmsEntrySettings>;
|
|
28
|
+
export declare function layoutFromConfig(input: CmsLayoutBlock[] | undefined): CmsFormLayout;
|
|
29
|
+
export declare function resolveFormLayout(fields: Record<string, FieldConfig>, layout: CmsFormLayout | undefined): CmsFormLayout;
|
|
30
|
+
export declare function defaultListColumns(fields: Record<string, FieldConfig>, list: CmsListInput | undefined, extra?: string[]): string[];
|
|
31
|
+
export declare function resolveListColumns(fields: Record<string, FieldConfig>, saved: string[] | undefined, list: CmsListInput | undefined, extra?: string[]): string[];
|
|
32
|
+
export declare function layoutErrors(at: string, fields: Record<string, FieldConfig>, input: CmsLayoutBlock[] | undefined): string[];
|
|
33
|
+
export declare function listErrors(at: string, fields: Record<string, FieldConfig>, list: CmsListInput | undefined, extra?: string[]): string[];
|