@xleddyl/nuxt-cms 0.1.60 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/module.json +1 -1
- package/dist/module.mjs +137 -9
- 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/EmptyState.vue +4 -2
- package/dist/runtime/app/components/cms/EntryDrawer.vue +1 -0
- package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +1 -0
- package/dist/runtime/app/components/cms/EntryForm.vue +105 -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/MediaDetail.d.vue.ts +16 -0
- package/dist/runtime/app/components/cms/MediaDetail.vue +169 -0
- package/dist/runtime/app/components/cms/MediaDetail.vue.d.ts +16 -0
- package/dist/runtime/app/components/cms/MediaField.vue +9 -2
- package/dist/runtime/app/components/cms/MediaFolderPicker.d.vue.ts +2 -2
- package/dist/runtime/app/components/cms/MediaFolderPicker.vue +55 -54
- package/dist/runtime/app/components/cms/MediaFolderPicker.vue.d.ts +2 -2
- package/dist/runtime/app/components/cms/MediaGallery.d.vue.ts +8 -2
- package/dist/runtime/app/components/cms/MediaGallery.vue +927 -336
- package/dist/runtime/app/components/cms/MediaGallery.vue.d.ts +8 -2
- package/dist/runtime/app/components/cms/MediaUpload.d.vue.ts +1 -0
- package/dist/runtime/app/components/cms/MediaUpload.vue +76 -139
- package/dist/runtime/app/components/cms/MediaUpload.vue.d.ts +1 -0
- 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 +77 -0
- package/dist/runtime/app/components/cms/PasswordInput.vue.d.ts +17 -0
- package/dist/runtime/app/components/cms/SelectMenu.vue +3 -5
- 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 +133 -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 +179 -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 +270 -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 +7 -2
- package/dist/runtime/app/components/cms/Table.vue +38 -1
- package/dist/runtime/app/components/cms/Table.vue.d.ts +7 -2
- 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-media-library.d.ts +75 -0
- package/dist/runtime/app/composables/cms-media-library.js +151 -0
- package/dist/runtime/app/composables/cms-media-uploader.d.ts +22 -0
- package/dist/runtime/app/composables/cms-media-uploader.js +244 -0
- package/dist/runtime/app/composables/cms-settings.d.ts +13 -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 +1 -0
- package/dist/runtime/app/pages/admin-login.vue +18 -3
- package/dist/runtime/app/pages/admin-media.vue +19 -3
- 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/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/media-delete.post.d.ts +4 -0
- package/dist/runtime/server/api/media-delete.post.js +23 -0
- package/dist/runtime/server/api/media-folder.delete.js +6 -16
- package/dist/runtime/server/api/media-folder.patch.d.ts +5 -0
- package/dist/runtime/server/api/media-folder.patch.js +68 -0
- package/dist/runtime/server/api/media-folder.post.js +3 -15
- package/dist/runtime/server/api/media-move.post.d.ts +5 -0
- package/dist/runtime/server/api/media-move.post.js +28 -0
- package/dist/runtime/server/api/media-usage.post.d.ts +5 -0
- package/dist/runtime/server/api/media-usage.post.js +15 -0
- 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 +32 -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 +18 -8
- package/dist/runtime/server/utils/media-folders.d.ts +86 -0
- package/dist/runtime/server/utils/media-folders.js +37 -0
- package/dist/runtime/server/utils/media-references.d.ts +7 -1
- package/dist/runtime/server/utils/media-references.js +40 -0
- package/dist/runtime/server/utils/media-usage.d.ts +2 -0
- package/dist/runtime/server/utils/media-usage.js +18 -0
- 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 +6 -2
- package/dist/runtime/server/utils/require-admin.js +33 -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 +29 -1
- package/dist/runtime/shared/index.js +36 -2
- package/dist/runtime/shared/layout.d.ts +34 -0
- package/dist/runtime/shared/layout.js +130 -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,15 @@
|
|
|
1
|
+
import { defineEventHandler } from "h3";
|
|
2
|
+
import { useDb } from "#cms-db";
|
|
3
|
+
import { cms_settings } from "#cms-tables";
|
|
4
|
+
import { requireAdmin } from "../utils/require-admin.js";
|
|
5
|
+
import { parseSettingsValue } from "../utils/settings.js";
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
await requireAdmin(event);
|
|
8
|
+
const rows = await useDb().select().from(cms_settings);
|
|
9
|
+
return Object.fromEntries(
|
|
10
|
+
rows.flatMap((row) => {
|
|
11
|
+
const value = parseSettingsValue(row.value);
|
|
12
|
+
return value ? [[row.key, value]] : [];
|
|
13
|
+
})
|
|
14
|
+
);
|
|
15
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineEventHandler, readValidatedBody } from "h3";
|
|
2
|
+
import { useDb } from "#cms-db";
|
|
3
|
+
import { cms_settings } from "#cms-tables";
|
|
4
|
+
import { requireAdmin } from "../utils/require-admin.js";
|
|
5
|
+
import { entrySettingsSchema, requireSettingsKey } from "../utils/settings.js";
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
await requireAdmin(event);
|
|
8
|
+
const key = requireSettingsKey(event);
|
|
9
|
+
const body = await readValidatedBody(event, entrySettingsSchema.parse);
|
|
10
|
+
const value = JSON.stringify(body);
|
|
11
|
+
const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
12
|
+
await useDb().insert(cms_settings).values({ key, value, updatedAt }).onConflictDoUpdate({ target: cms_settings.key, set: { value, updatedAt } });
|
|
13
|
+
return body;
|
|
14
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import { createError, defineEventHandler, getRouterParam } from "h3";
|
|
3
|
+
import { useDb } from "#cms-db";
|
|
4
|
+
import { cms_users } from "#cms-tables";
|
|
5
|
+
import { requireSuperAdmin } from "../utils/require-admin.js";
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
await requireSuperAdmin(event);
|
|
8
|
+
const id = getRouterParam(event, "id") ?? "";
|
|
9
|
+
const [row] = await useDb().delete(cms_users).where(eq(cms_users.id, id)).returning();
|
|
10
|
+
if (!row) throw createError({ statusCode: 404, statusMessage: "User not found" });
|
|
11
|
+
return { ok: true };
|
|
12
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import { createError, defineEventHandler, getRouterParam, readValidatedBody } from "h3";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { useDb } from "#cms-db";
|
|
5
|
+
import { cms_users } from "#cms-tables";
|
|
6
|
+
import { MIN_PASSWORD_LENGTH } from "../../shared/index.js";
|
|
7
|
+
import { hashPassword } from "../utils/password.js";
|
|
8
|
+
import { requireSuperAdmin } from "../utils/require-admin.js";
|
|
9
|
+
import { toAccount } from "../utils/users.js";
|
|
10
|
+
const bodySchema = z.object({
|
|
11
|
+
name: z.string().trim().max(120).nullish(),
|
|
12
|
+
password: z.string().min(MIN_PASSWORD_LENGTH).max(256).optional()
|
|
13
|
+
});
|
|
14
|
+
export default defineEventHandler(async (event) => {
|
|
15
|
+
await requireSuperAdmin(event);
|
|
16
|
+
const id = getRouterParam(event, "id") ?? "";
|
|
17
|
+
const body = await readValidatedBody(event, bodySchema.parse);
|
|
18
|
+
const set = { updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
19
|
+
if (body.name !== void 0) set.name = body.name || null;
|
|
20
|
+
if (body.password) {
|
|
21
|
+
set.passwordHash = await hashPassword(body.password);
|
|
22
|
+
set.mustChangePassword = true;
|
|
23
|
+
}
|
|
24
|
+
const [row] = await useDb().update(cms_users).set(set).where(eq(cms_users.id, id)).returning();
|
|
25
|
+
if (!row) throw createError({ statusCode: 404, statusMessage: "User not found" });
|
|
26
|
+
return toAccount(row);
|
|
27
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { asc } from "drizzle-orm";
|
|
2
|
+
import { defineEventHandler } from "h3";
|
|
3
|
+
import { useDb } from "#cms-db";
|
|
4
|
+
import { cms_users } from "#cms-tables";
|
|
5
|
+
import { requireSuperAdmin } from "../utils/require-admin.js";
|
|
6
|
+
import { toAccount } from "../utils/users.js";
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
await requireSuperAdmin(event);
|
|
9
|
+
const rows = await useDb().select().from(cms_users).orderBy(asc(cms_users.createdAt));
|
|
10
|
+
return rows.map(toAccount);
|
|
11
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
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 { hashPassword } from "../utils/password.js";
|
|
8
|
+
import { isSuperAdminEmail, requireSuperAdmin } from "../utils/require-admin.js";
|
|
9
|
+
import { findUserByEmail, toAccount } from "../utils/users.js";
|
|
10
|
+
const bodySchema = z.object({
|
|
11
|
+
email: z.string().trim().toLowerCase().email().max(254),
|
|
12
|
+
name: z.string().trim().max(120).nullish(),
|
|
13
|
+
password: z.string().min(MIN_PASSWORD_LENGTH).max(256)
|
|
14
|
+
});
|
|
15
|
+
export default defineEventHandler(async (event) => {
|
|
16
|
+
await requireSuperAdmin(event);
|
|
17
|
+
const body = await readValidatedBody(event, bodySchema.parse);
|
|
18
|
+
if (isSuperAdminEmail(event, body.email) || await findUserByEmail(body.email)) {
|
|
19
|
+
throw createError({ statusCode: 409, statusMessage: "A user with this email already exists" });
|
|
20
|
+
}
|
|
21
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
22
|
+
const [row] = await useDb().insert(cms_users).values({
|
|
23
|
+
id: customId("usr"),
|
|
24
|
+
email: body.email,
|
|
25
|
+
name: body.name || null,
|
|
26
|
+
role: "admin",
|
|
27
|
+
passwordHash: await hashPassword(body.password),
|
|
28
|
+
createdAt: now,
|
|
29
|
+
updatedAt: now
|
|
30
|
+
}).returning();
|
|
31
|
+
return toAccount(row);
|
|
32
|
+
});
|
|
@@ -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,8 +1,13 @@
|
|
|
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
12
|
email: z.string().trim().min(1),
|
|
8
13
|
password: z.string().min(1)
|
|
@@ -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
|
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { MediaStore } from './media.js';
|
|
2
|
+
export declare function writeMediaFolderMarker(store: MediaStore, folder: string): Promise<void>;
|
|
3
|
+
export declare function mediaRowsInFolder(folder: string): Omit<import("drizzle-orm/sqlite-core").SQLiteSelectBase<"cms_media", "sync", import("better-sqlite3").RunResult, {
|
|
4
|
+
key: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
5
|
+
name: "key";
|
|
6
|
+
tableName: "cms_media";
|
|
7
|
+
dataType: "string";
|
|
8
|
+
columnType: "SQLiteText";
|
|
9
|
+
data: string;
|
|
10
|
+
driverParam: string;
|
|
11
|
+
notNull: true;
|
|
12
|
+
hasDefault: false;
|
|
13
|
+
isPrimaryKey: false;
|
|
14
|
+
isAutoincrement: false;
|
|
15
|
+
hasRuntimeDefault: false;
|
|
16
|
+
enumValues: [string, ...string[]];
|
|
17
|
+
baseColumn: never;
|
|
18
|
+
identity: undefined;
|
|
19
|
+
generated: undefined;
|
|
20
|
+
}, {}, {
|
|
21
|
+
length: number | undefined;
|
|
22
|
+
}>;
|
|
23
|
+
folder: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
24
|
+
name: "folder";
|
|
25
|
+
tableName: "cms_media";
|
|
26
|
+
dataType: "string";
|
|
27
|
+
columnType: "SQLiteText";
|
|
28
|
+
data: string;
|
|
29
|
+
driverParam: string;
|
|
30
|
+
notNull: false;
|
|
31
|
+
hasDefault: false;
|
|
32
|
+
isPrimaryKey: false;
|
|
33
|
+
isAutoincrement: false;
|
|
34
|
+
hasRuntimeDefault: false;
|
|
35
|
+
enumValues: [string, ...string[]];
|
|
36
|
+
baseColumn: never;
|
|
37
|
+
identity: undefined;
|
|
38
|
+
generated: undefined;
|
|
39
|
+
}, {}, {
|
|
40
|
+
length: number | undefined;
|
|
41
|
+
}>;
|
|
42
|
+
}, "partial", Record<"cms_media", "not-null">, false, "where", {
|
|
43
|
+
key: string;
|
|
44
|
+
folder: string | null;
|
|
45
|
+
}[], {
|
|
46
|
+
key: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
47
|
+
name: "key";
|
|
48
|
+
tableName: "cms_media";
|
|
49
|
+
dataType: "string";
|
|
50
|
+
columnType: "SQLiteText";
|
|
51
|
+
data: string;
|
|
52
|
+
driverParam: string;
|
|
53
|
+
notNull: true;
|
|
54
|
+
hasDefault: false;
|
|
55
|
+
isPrimaryKey: false;
|
|
56
|
+
isAutoincrement: false;
|
|
57
|
+
hasRuntimeDefault: false;
|
|
58
|
+
enumValues: [string, ...string[]];
|
|
59
|
+
baseColumn: never;
|
|
60
|
+
identity: undefined;
|
|
61
|
+
generated: undefined;
|
|
62
|
+
}, {}, {
|
|
63
|
+
length: number | undefined;
|
|
64
|
+
}>;
|
|
65
|
+
folder: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
66
|
+
name: "folder";
|
|
67
|
+
tableName: "cms_media";
|
|
68
|
+
dataType: "string";
|
|
69
|
+
columnType: "SQLiteText";
|
|
70
|
+
data: string;
|
|
71
|
+
driverParam: string;
|
|
72
|
+
notNull: false;
|
|
73
|
+
hasDefault: false;
|
|
74
|
+
isPrimaryKey: false;
|
|
75
|
+
isAutoincrement: false;
|
|
76
|
+
hasRuntimeDefault: false;
|
|
77
|
+
enumValues: [string, ...string[]];
|
|
78
|
+
baseColumn: never;
|
|
79
|
+
identity: undefined;
|
|
80
|
+
generated: undefined;
|
|
81
|
+
}, {}, {
|
|
82
|
+
length: number | undefined;
|
|
83
|
+
}>;
|
|
84
|
+
}>, "where">;
|
|
85
|
+
export declare function deleteMediaRows(store: MediaStore, keys: string[]): Promise<void>;
|
|
86
|
+
export declare function keyChunks(keys: string[]): string[][];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { inArray, like, or } from "drizzle-orm";
|
|
2
|
+
import { useDb } from "#cms-db";
|
|
3
|
+
import { cms_media } from "#cms-tables";
|
|
4
|
+
import { mediaFolderMarkerKey } from "../../shared/index.js";
|
|
5
|
+
const KEY_CHUNK = 90;
|
|
6
|
+
export async function writeMediaFolderMarker(store, folder) {
|
|
7
|
+
const key = mediaFolderMarkerKey(folder);
|
|
8
|
+
await store.write(key, new Uint8Array(), "application/x-empty");
|
|
9
|
+
const values = {
|
|
10
|
+
key,
|
|
11
|
+
mime: "application/x-empty",
|
|
12
|
+
size: 0,
|
|
13
|
+
width: null,
|
|
14
|
+
height: null,
|
|
15
|
+
alt: null,
|
|
16
|
+
folder
|
|
17
|
+
};
|
|
18
|
+
await useDb().insert(cms_media).values(values).onConflictDoUpdate({ target: cms_media.key, set: values });
|
|
19
|
+
}
|
|
20
|
+
export function mediaRowsInFolder(folder) {
|
|
21
|
+
return useDb().select({ key: cms_media.key, folder: cms_media.folder }).from(cms_media).where(or(like(cms_media.folder, `${folder}/%`), like(cms_media.folder, folder)));
|
|
22
|
+
}
|
|
23
|
+
export async function deleteMediaRows(store, keys) {
|
|
24
|
+
for (const key of keys) {
|
|
25
|
+
await store.remove(key);
|
|
26
|
+
}
|
|
27
|
+
for (const chunk of keyChunks(keys)) {
|
|
28
|
+
await useDb().delete(cms_media).where(inArray(cms_media.key, chunk));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function keyChunks(keys) {
|
|
32
|
+
const chunks = [];
|
|
33
|
+
for (let index = 0; index < keys.length; index += KEY_CHUNK) {
|
|
34
|
+
chunks.push(keys.slice(index, index + KEY_CHUNK));
|
|
35
|
+
}
|
|
36
|
+
return chunks;
|
|
37
|
+
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import type { BaseSQLiteDatabase, SQLiteTable } from 'drizzle-orm/sqlite-core';
|
|
2
|
-
import type { FieldConfig } from '../../shared/index.js';
|
|
2
|
+
import type { CmsEntry, FieldConfig, MediaUsage } from '../../shared/index.js';
|
|
3
3
|
export interface MediaReference {
|
|
4
4
|
field: string;
|
|
5
5
|
key: string;
|
|
6
6
|
}
|
|
7
7
|
export type MediaLookup = (keys: string[]) => Promise<Set<string>>;
|
|
8
8
|
export declare function mediaReferences(fields: Record<string, FieldConfig>, values: Record<string, unknown>): MediaReference[];
|
|
9
|
+
export interface MediaUsageSource {
|
|
10
|
+
name: string;
|
|
11
|
+
entry: CmsEntry;
|
|
12
|
+
rows: Record<string, unknown>[];
|
|
13
|
+
}
|
|
14
|
+
export declare function collectMediaUsage(sources: MediaUsageSource[], keys: string[]): Record<string, MediaUsage[]>;
|
|
9
15
|
export declare function assertKnownMedia(references: MediaReference[], lookup: MediaLookup): Promise<void>;
|
|
10
16
|
export declare function mediaKeysInTable(db: Pick<BaseSQLiteDatabase<'sync' | 'async', unknown>, 'select'>, table: SQLiteTable, keys: string[]): Promise<Set<string>>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { inArray } from "drizzle-orm";
|
|
2
2
|
import { createError } from "h3";
|
|
3
|
+
import { PAGE_PATH_FIELD, pageFields, pageRouteOf } from "../../shared/index.js";
|
|
3
4
|
const LOOKUP_CHUNK = 90;
|
|
4
5
|
function mediaKeysOf(value) {
|
|
5
6
|
if (typeof value === "string") return value ? [value] : [];
|
|
@@ -29,6 +30,45 @@ export function mediaReferences(fields, values) {
|
|
|
29
30
|
}
|
|
30
31
|
return references;
|
|
31
32
|
}
|
|
33
|
+
function titleOf(entry, row) {
|
|
34
|
+
if (entry.kind === "page") {
|
|
35
|
+
const path = row[PAGE_PATH_FIELD];
|
|
36
|
+
return pageRouteOf(entry, String(row.id))?.label ?? (typeof path === "string" ? path : null);
|
|
37
|
+
}
|
|
38
|
+
const value = entry.titleField ? row[entry.titleField] : void 0;
|
|
39
|
+
if (typeof value === "string") return value || null;
|
|
40
|
+
if (value && typeof value === "object") {
|
|
41
|
+
const first = Object.values(value).find(
|
|
42
|
+
(candidate) => typeof candidate === "string" && candidate !== ""
|
|
43
|
+
);
|
|
44
|
+
return first ?? null;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
function fieldsOf(entry, row) {
|
|
49
|
+
const path = row[PAGE_PATH_FIELD];
|
|
50
|
+
return entry.kind === "page" && typeof path === "string" ? pageFields(entry, path) : entry.fields;
|
|
51
|
+
}
|
|
52
|
+
export function collectMediaUsage(sources, keys) {
|
|
53
|
+
const usage = Object.fromEntries(keys.map((key) => [key, []]));
|
|
54
|
+
for (const { name, entry, rows } of sources) {
|
|
55
|
+
for (const row of rows) {
|
|
56
|
+
for (const reference of mediaReferences(fieldsOf(entry, row), row)) {
|
|
57
|
+
const list = usage[reference.key];
|
|
58
|
+
if (!list) continue;
|
|
59
|
+
list.push({
|
|
60
|
+
collection: name,
|
|
61
|
+
label: entry.label,
|
|
62
|
+
kind: entry.kind,
|
|
63
|
+
id: entry.kind === "single" ? null : String(row.id),
|
|
64
|
+
title: titleOf(entry, row),
|
|
65
|
+
field: reference.field
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return usage;
|
|
71
|
+
}
|
|
32
72
|
export async function assertKnownMedia(references, lookup) {
|
|
33
73
|
if (!references.length) return;
|
|
34
74
|
const known = await lookup([...new Set(references.map((reference) => reference.key))]);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import cmsConfig from "#cms-config";
|
|
2
|
+
import { useDb } from "#cms-db";
|
|
3
|
+
import { collectMediaUsage } from "./media-references.js";
|
|
4
|
+
import { readPages } from "./page-storage.js";
|
|
5
|
+
import { decodeRows, resolveTable } from "./registry.js";
|
|
6
|
+
async function usageSources() {
|
|
7
|
+
const sources = [];
|
|
8
|
+
for (const [name, entry] of Object.entries(cmsConfig)) {
|
|
9
|
+
const table = resolveTable(name);
|
|
10
|
+
if (!table) continue;
|
|
11
|
+
const rows = entry.kind === "page" ? await readPages(name, entry, table) : decodeRows(entry, await useDb().select().from(table));
|
|
12
|
+
sources.push({ name, entry, rows });
|
|
13
|
+
}
|
|
14
|
+
return sources;
|
|
15
|
+
}
|
|
16
|
+
export async function loadMediaUsage(keys) {
|
|
17
|
+
return collectMediaUsage(await usageSources(), keys);
|
|
18
|
+
}
|
|
@@ -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,7 @@
|
|
|
1
1
|
import type { H3Event } from 'h3';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import type { CmsSessionUser } from '../../shared/index.js';
|
|
3
|
+
import { assertSameOrigin } from './same-origin.js';
|
|
4
|
+
export { assertSameOrigin };
|
|
5
|
+
export declare function isSuperAdminEmail(event: H3Event, email: string): boolean;
|
|
6
|
+
export declare function requireAdmin(event: H3Event): Promise<CmsSessionUser>;
|
|
7
|
+
export declare function requireSuperAdmin(event: H3Event): Promise<CmsSessionUser>;
|
|
@@ -1,37 +1,42 @@
|
|
|
1
|
-
import { createError
|
|
1
|
+
import { createError } from "h3";
|
|
2
2
|
import { getUserSession, useRuntimeConfig } from "#imports";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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 { assertSameOrigin };
|
|
6
|
+
export function isSuperAdminEmail(event, email) {
|
|
7
|
+
const { adminEmail } = useRuntimeConfig(event).cms;
|
|
8
|
+
return !!adminEmail && email.toLowerCase() === adminEmail.toLowerCase();
|
|
25
9
|
}
|
|
26
10
|
export async function requireAdmin(event) {
|
|
27
11
|
assertSameOrigin(event);
|
|
28
12
|
const session = await getUserSession(event);
|
|
29
|
-
const
|
|
30
|
-
if (!email) {
|
|
13
|
+
const user = session.user;
|
|
14
|
+
if (!user?.email) {
|
|
31
15
|
throw createError({ statusCode: 401, statusMessage: "Authentication required" });
|
|
32
16
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
17
|
+
if (!user.id) {
|
|
18
|
+
if (!isSuperAdminEmail(event, user.email)) {
|
|
19
|
+
throw createError({ statusCode: 403, statusMessage: "Not authorized" });
|
|
20
|
+
}
|
|
21
|
+
return { email: user.email.toLowerCase(), name: null, role: "superadmin" };
|
|
22
|
+
}
|
|
23
|
+
const row = await findUserById(user.id);
|
|
24
|
+
if (!row || user.passwordStamp !== passwordStamp(row.passwordHash)) {
|
|
25
|
+
throw createError({ statusCode: 401, statusMessage: "Authentication required" });
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
id: row.id,
|
|
29
|
+
email: row.email,
|
|
30
|
+
name: row.name ?? null,
|
|
31
|
+
role: "admin",
|
|
32
|
+
firstLogin: !!row.mustChangePassword,
|
|
33
|
+
passwordStamp: user.passwordStamp
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export async function requireSuperAdmin(event) {
|
|
37
|
+
const user = await requireAdmin(event);
|
|
38
|
+
if (user.role !== "superadmin") {
|
|
39
|
+
throw createError({ statusCode: 403, statusMessage: "Only the super admin can manage users" });
|
|
36
40
|
}
|
|
41
|
+
return user;
|
|
37
42
|
}
|
|
@@ -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;
|