@xleddyl/nuxt-cms 0.1.36 → 0.1.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -5
- package/dist/module.d.mts +7 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +106 -9
- package/dist/runtime/server/plugins/d1-binding.d.ts +3 -0
- package/dist/runtime/server/plugins/d1-binding.js +15 -0
- package/dist/runtime/server/plugins/migrate-libsql.js +2 -14
- package/dist/runtime/server/plugins/migrate-postgres.js +2 -14
- package/dist/runtime/server/plugins/migrate-sqlite.d.ts +1 -1
- package/dist/runtime/server/plugins/migrate-sqlite.js +3 -15
- package/dist/runtime/server/routes/auth/login.post.js +42 -26
- package/dist/runtime/server/utils/db-d1.d.ts +11 -0
- package/dist/runtime/server/utils/db-d1.js +26 -0
- package/dist/runtime/server/utils/db-libsql.js +10 -11
- package/dist/runtime/server/utils/db-postgres.js +4 -2
- package/dist/runtime/server/utils/migrate.d.ts +8 -0
- package/dist/runtime/server/utils/migrate.js +51 -0
- package/dist/runtime/server/utils/require-admin.d.ts +1 -0
- package/dist/runtime/server/utils/require-admin.js +18 -8
- package/package.json +22 -4
package/README.md
CHANGED
|
@@ -10,7 +10,8 @@ nuxt-cms is a Nuxt module that leverages the Nitro server to ship a lightweight
|
|
|
10
10
|
- **Content types in code**: a `cms.config.ts` with `defineCmsConfig()` declares collections, single documents, relations, blocks and translatable fields; database schema, migrations and TypeScript types are generated from it.
|
|
11
11
|
- **Admin panel at `/cms`**: entry editing with validation, drafts, media library (S3-compatible storage, or a local mode backed directly by your `public/` folder), single-admin auth from env credentials.
|
|
12
12
|
- **Public GraphQL API**: read-only, typed end-to-end via gql.tada, with filtering, sorting and pagination; fields marked `private` stay out of it.
|
|
13
|
-
- **SQLite, Postgres
|
|
13
|
+
- **SQLite, Postgres, libSQL/Turso or Cloudflare D1**: a local file database by default, one config line to switch (including remote SQLite over the network).
|
|
14
|
+
- **Runs on serverless too**: migrations are baked into the server bundle, uploads are presigned straight to your bucket, sessions are sealed cookies. Nothing on the request path needs a local disk or sticky instances.
|
|
14
15
|
|
|
15
16
|
## Screenshots
|
|
16
17
|
|
|
@@ -41,9 +42,15 @@ export default defineNuxtConfig({
|
|
|
41
42
|
```
|
|
42
43
|
|
|
43
44
|
`database` defaults to SQLite (`{ driver: 'sqlite', path: 'data/cms.db' }`) and can be omitted
|
|
44
|
-
entirely. Switch it to `{ driver: 'postgres', url: '...' }
|
|
45
|
-
`{ driver: 'libsql', url: '...', authToken: '...' }` (Turso/remote)
|
|
46
|
-
|
|
45
|
+
entirely. Switch it to `{ driver: 'postgres', url: '...' }`,
|
|
46
|
+
`{ driver: 'libsql', url: '...', authToken: '...' }` (Turso/remote) or
|
|
47
|
+
`{ driver: 'd1', binding: 'DB' }` (Cloudflare Workers); see
|
|
48
|
+
[Configuration](docs/configuration.md) for every option and
|
|
49
|
+
[Deployment](docs/deployment.md) for which driver each host supports.
|
|
50
|
+
|
|
51
|
+
Each driver brings its own client, and only the one you use has to be installed: SQLite works out of
|
|
52
|
+
the box, `postgres` needs `pg`, `libsql` needs `@libsql/client`, and `d1` needs nothing extra. The
|
|
53
|
+
build stops with an explicit message if the client for the configured driver is missing.
|
|
47
54
|
|
|
48
55
|
Then declare your content types in a `cms.config.ts` at the project root with `defineCmsConfig()`.
|
|
49
56
|
|
|
@@ -74,6 +81,7 @@ Every secret maps to runtime config, so it can be set as an env var instead of i
|
|
|
74
81
|
| `NUXT_SESSION_PASSWORD` | in production | session encryption key (32+ chars) |
|
|
75
82
|
| `NUXT_CMS_DATABASE_URL` | with `postgres` / remote `libsql` | Postgres connection string or libSQL URL |
|
|
76
83
|
| `NUXT_CMS_DATABASE_AUTH_TOKEN` | with remote `libsql` | libSQL/Turso auth token |
|
|
84
|
+
| `NUXT_CMS_MIGRATE_ON_BOOT` | no | `false` to apply migrations in CI instead of on boot |
|
|
77
85
|
| `NUXT_CMS_MEDIA_ENDPOINT` | for media | S3-compatible endpoint |
|
|
78
86
|
| `NUXT_CMS_MEDIA_REGION` | for media | S3 region (default `auto`) |
|
|
79
87
|
| `NUXT_CMS_MEDIA_BUCKET` | for media | bucket name |
|
|
@@ -87,11 +95,12 @@ Full documentation lives in [`docs/`](docs/README.md):
|
|
|
87
95
|
|
|
88
96
|
- [Getting started](docs/getting-started.md) — install, configure, first content type, run.
|
|
89
97
|
- [Configuration](docs/configuration.md) — every `cms.*` option and the `NUXT_CMS_*` env vars.
|
|
90
|
-
- [Database](docs/database.md) — SQLite, Postgres
|
|
98
|
+
- [Database](docs/database.md) — SQLite, Postgres, libSQL/Turso and D1 drivers, migrations, studio.
|
|
91
99
|
- [Schema](docs/schema.md) — `defineCmsConfig`, entries, field types, relations, blocks, i18n.
|
|
92
100
|
- [Querying content](docs/querying.md) — GraphQL API, `useCms` / `$cmsQuery`, filters, sorting, pagination.
|
|
93
101
|
- [Admin panel & security](docs/admin.md) — pages, authentication, sessions, admin REST API.
|
|
94
102
|
- [Media](docs/media.md) — S3-compatible storage or local mode backed by your `public/` folder, upload flow, allowed file types.
|
|
103
|
+
- [Deployment](docs/deployment.md) — host/driver matrix, migrations on serverless, horizontal scaling, Cloudflare Workers.
|
|
95
104
|
|
|
96
105
|
## LLM guide
|
|
97
106
|
|
package/dist/module.d.mts
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import * as _nuxt_schema from '@nuxt/schema';
|
|
2
2
|
|
|
3
3
|
type ModuleOptionsDatabase = {
|
|
4
|
+
migrateOnBoot?: boolean;
|
|
5
|
+
} & ({
|
|
4
6
|
driver?: 'sqlite';
|
|
5
7
|
path?: string;
|
|
6
8
|
} | {
|
|
7
9
|
driver: 'postgres';
|
|
8
10
|
url?: string;
|
|
11
|
+
poolMax?: number;
|
|
9
12
|
} | {
|
|
10
13
|
driver: 'libsql';
|
|
11
14
|
url?: string;
|
|
12
15
|
authToken?: string;
|
|
13
|
-
}
|
|
16
|
+
} | {
|
|
17
|
+
driver: 'd1';
|
|
18
|
+
binding?: string;
|
|
19
|
+
});
|
|
14
20
|
type ModuleOptionsMedia = {
|
|
15
21
|
storage?: 's3';
|
|
16
22
|
endpoint?: string;
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
|
-
import { isAbsolute, resolve, relative,
|
|
4
|
+
import { join, isAbsolute, resolve, relative, dirname } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { defineNuxtModule, createResolver, useLogger, addImports, resolvePath, addTemplate, addServerPlugin, addTypeTemplate, addVitePlugin, addComponentsDir, addRouteMiddleware, extendPages, addServerHandler } from '@nuxt/kit';
|
|
7
7
|
import tailwindcss from '@tailwindcss/vite';
|
|
@@ -12,6 +12,8 @@ import { createJiti } from 'jiti';
|
|
|
12
12
|
import { typeName, blockTypeName, blockUnionName, renderGraphqlSdl } from '../dist/runtime/shared/graphql-sdl.js';
|
|
13
13
|
import { isMultiSelect, fieldConditions, isTranslatableField, isTranslatableMediaField, isRequiredField, isPrivateField, DEFAULT_MEDIA_MAX_FILE_SIZE } from '../dist/runtime/shared/index.js';
|
|
14
14
|
import { scanMediaDirectory, readMediaFileMeta } from '../dist/runtime/server/utils/media-sync.js';
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
15
17
|
|
|
16
18
|
async function collectMediaManifest(root) {
|
|
17
19
|
const files = await scanMediaDirectory(root);
|
|
@@ -40,6 +42,41 @@ function renderMediaManifestTypes(typesPath) {
|
|
|
40
42
|
].join("\n");
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
async function collectMigrations(migrationsDir) {
|
|
46
|
+
const journalPath = join(migrationsDir, "meta", "_journal.json");
|
|
47
|
+
if (!existsSync(journalPath)) return null;
|
|
48
|
+
const journal = JSON.parse(await readFile(journalPath, "utf8"));
|
|
49
|
+
const migrations = [];
|
|
50
|
+
for (const entry of journal.entries ?? []) {
|
|
51
|
+
const query = await readFile(join(migrationsDir, `${entry.tag}.sql`), "utf8");
|
|
52
|
+
migrations.push({
|
|
53
|
+
sql: query.split("--> statement-breakpoint"),
|
|
54
|
+
bps: entry.breakpoints,
|
|
55
|
+
folderMillis: entry.when,
|
|
56
|
+
hash: createHash("sha256").update(query).digest("hex")
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return migrations;
|
|
60
|
+
}
|
|
61
|
+
function renderMigrationsFile(migrations) {
|
|
62
|
+
return [
|
|
63
|
+
`export const generated = ${migrations !== null}`,
|
|
64
|
+
``,
|
|
65
|
+
`export const migrations = ${JSON.stringify(migrations ?? [], null, 3)}`,
|
|
66
|
+
``
|
|
67
|
+
].join("\n");
|
|
68
|
+
}
|
|
69
|
+
function renderMigrationsTypes(typesPath) {
|
|
70
|
+
return [
|
|
71
|
+
`import type { CmsMigration } from '${typesPath}'`,
|
|
72
|
+
``,
|
|
73
|
+
`export declare const generated: boolean`,
|
|
74
|
+
``,
|
|
75
|
+
`export declare const migrations: CmsMigration[]`,
|
|
76
|
+
``
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
43
80
|
const IDENTIFIER = /^[a-z_]\w*$/i;
|
|
44
81
|
const RESERVED_ENTRY_KEYS = ["admin", "auth", "login", "media", "graphql", "cms_media"];
|
|
45
82
|
const RESERVED_COLUMNS = ["id", "status", "created_at", "updated_at"];
|
|
@@ -517,18 +554,34 @@ function resolveCmsEnabled(explicit, envValue) {
|
|
|
517
554
|
}
|
|
518
555
|
|
|
519
556
|
function resolveDatabaseOptions(database) {
|
|
557
|
+
const shared = {
|
|
558
|
+
migrateOnBoot: database?.migrateOnBoot ?? true,
|
|
559
|
+
poolMax: 0,
|
|
560
|
+
binding: "",
|
|
561
|
+
path: "data/cms.db",
|
|
562
|
+
url: "",
|
|
563
|
+
authToken: ""
|
|
564
|
+
};
|
|
520
565
|
if (database?.driver === "postgres") {
|
|
521
|
-
return {
|
|
566
|
+
return {
|
|
567
|
+
...shared,
|
|
568
|
+
driver: "postgres",
|
|
569
|
+
url: database.url ?? "",
|
|
570
|
+
poolMax: database.poolMax ?? 0
|
|
571
|
+
};
|
|
522
572
|
}
|
|
523
573
|
if (database?.driver === "libsql") {
|
|
524
574
|
return {
|
|
575
|
+
...shared,
|
|
525
576
|
driver: "libsql",
|
|
526
|
-
path: "data/cms.db",
|
|
527
577
|
url: database.url ?? "",
|
|
528
578
|
authToken: database.authToken ?? ""
|
|
529
579
|
};
|
|
530
580
|
}
|
|
531
|
-
|
|
581
|
+
if (database?.driver === "d1") {
|
|
582
|
+
return { ...shared, driver: "d1", binding: database.binding ?? "DB" };
|
|
583
|
+
}
|
|
584
|
+
return { ...shared, driver: "sqlite", path: database?.path ?? "data/cms.db" };
|
|
532
585
|
}
|
|
533
586
|
function resolveMediaOptions(media) {
|
|
534
587
|
if (media?.storage === "local") {
|
|
@@ -591,7 +644,8 @@ const module$1 = defineNuxtModule({
|
|
|
591
644
|
},
|
|
592
645
|
database: {
|
|
593
646
|
driver: "sqlite",
|
|
594
|
-
path: "data/cms.db"
|
|
647
|
+
path: "data/cms.db",
|
|
648
|
+
migrateOnBoot: true
|
|
595
649
|
},
|
|
596
650
|
media: {
|
|
597
651
|
storage: "s3",
|
|
@@ -759,11 +813,24 @@ const module$1 = defineNuxtModule({
|
|
|
759
813
|
driver,
|
|
760
814
|
path: dbPath,
|
|
761
815
|
url: databaseUrl,
|
|
762
|
-
authToken: databaseAuthToken
|
|
816
|
+
authToken: databaseAuthToken,
|
|
817
|
+
binding: d1Binding,
|
|
818
|
+
migrateOnBoot,
|
|
819
|
+
poolMax
|
|
763
820
|
} = resolved.database;
|
|
764
821
|
nuxt.options.alias["#cms-db"] = resolver.resolve(
|
|
765
|
-
driver === "postgres" ? "./runtime/server/utils/db-postgres" : driver === "libsql" ? "./runtime/server/utils/db-libsql" : "./runtime/server/utils/db-sqlite"
|
|
822
|
+
driver === "postgres" ? "./runtime/server/utils/db-postgres" : driver === "libsql" ? "./runtime/server/utils/db-libsql" : driver === "d1" ? "./runtime/server/utils/db-d1" : "./runtime/server/utils/db-sqlite"
|
|
766
823
|
);
|
|
824
|
+
const driverPackage = driver === "postgres" ? "pg" : driver === "libsql" ? "@libsql/client" : "better-sqlite3";
|
|
825
|
+
if (driver !== "d1") {
|
|
826
|
+
try {
|
|
827
|
+
resolveImport(driverPackage);
|
|
828
|
+
} catch {
|
|
829
|
+
throw new Error(
|
|
830
|
+
`[nuxt-cms] database.driver is '${driver}' but '${driverPackage}' is not installed. Install it in your app (e.g. pnpm add ${driverPackage}); only the client for the driver you use is required.`
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
767
834
|
const dialect = driver === "postgres" ? "postgresql" : driver === "libsql" ? "turso" : "sqlite";
|
|
768
835
|
const resolvedDbPath = isAbsolute(dbPath) ? dbPath : resolve(nuxt.options.rootDir, dbPath);
|
|
769
836
|
const migrationsDir = resolve(nuxt.options.rootDir, `server/db/migrations/${driver}`);
|
|
@@ -778,11 +845,32 @@ const module$1 = defineNuxtModule({
|
|
|
778
845
|
` dialect: '${dialect}',`,
|
|
779
846
|
` schema: '${toPosix(schemaTemplate.dst)}',`,
|
|
780
847
|
` out: '${toPosix(relativeMigrationsDir)}',`,
|
|
781
|
-
driver === "postgres" ? ` dbCredentials: { url: process.env.NUXT_CMS_DATABASE_URL ?? '${databaseUrl}' },` : driver === "libsql" ? ` dbCredentials: { url: process.env.NUXT_CMS_DATABASE_URL ?? '${databaseUrl || `file:${toPosix(resolvedDbPath)}`}', authToken: (process.env.NUXT_CMS_DATABASE_AUTH_TOKEN ?? '${databaseAuthToken}') || undefined },` : ` dbCredentials: { url: '${toPosix(resolvedDbPath)}' },`,
|
|
848
|
+
driver === "postgres" ? ` dbCredentials: { url: process.env.NUXT_CMS_DATABASE_URL ?? '${databaseUrl}' },` : driver === "libsql" ? ` dbCredentials: { url: process.env.NUXT_CMS_DATABASE_URL ?? '${databaseUrl || `file:${toPosix(resolvedDbPath)}`}', authToken: (process.env.NUXT_CMS_DATABASE_AUTH_TOKEN ?? '${databaseAuthToken}') || undefined },` : driver === "d1" ? `` : ` dbCredentials: { url: '${toPosix(resolvedDbPath)}' },`,
|
|
782
849
|
`}`,
|
|
783
850
|
``
|
|
784
851
|
].join("\n")
|
|
785
852
|
});
|
|
853
|
+
addTemplate({
|
|
854
|
+
filename: "cms/migrations.d.ts",
|
|
855
|
+
write: true,
|
|
856
|
+
getContents: () => renderMigrationsTypes(toPosix(resolver.resolve("./runtime/server/utils/migrate")))
|
|
857
|
+
});
|
|
858
|
+
const migrationsTemplate = addTemplate({
|
|
859
|
+
filename: "cms/migrations.js",
|
|
860
|
+
write: true,
|
|
861
|
+
getContents: async () => {
|
|
862
|
+
const migrations = await collectMigrations(migrationsDir);
|
|
863
|
+
if (!migrations && !nuxt.options.dev && Object.keys(cmsConfig).length) {
|
|
864
|
+
logger.warn(
|
|
865
|
+
`[nuxt-cms] No migrations found at ${migrationsDir}. The CMS tables will not be created \u2014 run the dev server once to generate them and commit ${toPosix(
|
|
866
|
+
relativeMigrationsDir
|
|
867
|
+
)}.`
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
return renderMigrationsFile(migrations);
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
nuxt.options.alias["#cms-migrations"] = migrationsTemplate.dst;
|
|
786
874
|
const publicDir = resolve(nuxt.options.rootDir, nuxt.options.dir?.public ?? "public");
|
|
787
875
|
const mediaLocalRoot = resolved.media.storage === "local" && resolved.media.publicBaseUrl.startsWith("/") ? join(publicDir, ...resolved.media.publicBaseUrl.split("/").filter(Boolean)) : "";
|
|
788
876
|
if (resolved.media.storage === "local" && !mediaLocalRoot) {
|
|
@@ -817,7 +905,7 @@ const module$1 = defineNuxtModule({
|
|
|
817
905
|
nuxt.options.alias["#cms-media-manifest"] = mediaManifestTemplate.dst;
|
|
818
906
|
addServerPlugin(
|
|
819
907
|
resolver.resolve(
|
|
820
|
-
driver === "postgres" ? "./runtime/server/plugins/migrate-postgres" : driver === "libsql" ? "./runtime/server/plugins/migrate-libsql" : "./runtime/server/plugins/migrate-sqlite"
|
|
908
|
+
driver === "postgres" ? "./runtime/server/plugins/migrate-postgres" : driver === "libsql" ? "./runtime/server/plugins/migrate-libsql" : driver === "d1" ? "./runtime/server/plugins/d1-binding" : "./runtime/server/plugins/migrate-sqlite"
|
|
821
909
|
)
|
|
822
910
|
);
|
|
823
911
|
if (!nuxt.options.dev) {
|
|
@@ -851,6 +939,9 @@ const module$1 = defineNuxtModule({
|
|
|
851
939
|
databaseAuthToken,
|
|
852
940
|
dbPath: resolvedDbPath,
|
|
853
941
|
migrationsDir,
|
|
942
|
+
migrateOnBoot,
|
|
943
|
+
poolMax,
|
|
944
|
+
d1Binding,
|
|
854
945
|
...existingConfig,
|
|
855
946
|
graphql: {
|
|
856
947
|
graphiql: nuxt.options.dev,
|
|
@@ -891,6 +982,12 @@ const module$1 = defineNuxtModule({
|
|
|
891
982
|
"declare module '#imports' {",
|
|
892
983
|
" function getUserSession(event: H3Event): Promise<{ user?: import('#auth-utils').User }>",
|
|
893
984
|
" function setUserSession(event: H3Event, session: { user: import('#auth-utils').User }): Promise<unknown>",
|
|
985
|
+
" function useStorage(base?: string): {",
|
|
986
|
+
" getItem<T>(key: string): Promise<T | null>",
|
|
987
|
+
" setItem<T>(key: string, value: T, options?: { ttl?: number }): Promise<void>",
|
|
988
|
+
" removeItem(key: string): Promise<void>",
|
|
989
|
+
" getKeys(base?: string): Promise<string[]>",
|
|
990
|
+
" }",
|
|
894
991
|
"}",
|
|
895
992
|
"",
|
|
896
993
|
"export {}",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { setD1Env, useD1Binding } from "../utils/db-d1.js";
|
|
2
|
+
import { runD1Migrations } from "../utils/migrate.js";
|
|
3
|
+
let migrated = null;
|
|
4
|
+
export default (nitroApp) => {
|
|
5
|
+
nitroApp.hooks.hook("request", async (event) => {
|
|
6
|
+
const env = event.context?.cloudflare?.env;
|
|
7
|
+
if (!env) return;
|
|
8
|
+
setD1Env(env);
|
|
9
|
+
migrated ??= runD1Migrations(useD1Binding()).catch((error) => {
|
|
10
|
+
migrated = null;
|
|
11
|
+
throw error;
|
|
12
|
+
});
|
|
13
|
+
await migrated;
|
|
14
|
+
});
|
|
15
|
+
};
|
|
@@ -1,17 +1,5 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { migrate } from "drizzle-orm/libsql/migrator";
|
|
3
|
-
import cmsConfig from "#cms-config";
|
|
4
|
-
import { useRuntimeConfig } from "#imports";
|
|
5
1
|
import { useDb } from "../utils/db-libsql.js";
|
|
2
|
+
import { runCmsMigrations } from "../utils/migrate.js";
|
|
6
3
|
export default async () => {
|
|
7
|
-
|
|
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
|
-
}
|
|
16
|
-
await migrate(useDb(), { migrationsFolder: migrationsDir });
|
|
4
|
+
await runCmsMigrations(useDb());
|
|
17
5
|
};
|
|
@@ -1,17 +1,5 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
|
3
|
-
import cmsConfig from "#cms-config";
|
|
4
|
-
import { useRuntimeConfig } from "#imports";
|
|
5
1
|
import { useDb } from "../utils/db-postgres.js";
|
|
2
|
+
import { runCmsMigrations } from "../utils/migrate.js";
|
|
6
3
|
export default async () => {
|
|
7
|
-
|
|
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
|
-
}
|
|
16
|
-
await migrate(useDb(), { migrationsFolder: migrationsDir });
|
|
4
|
+
await runCmsMigrations(useDb());
|
|
17
5
|
};
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: () => void
|
|
1
|
+
declare const _default: () => Promise<void>;
|
|
2
2
|
export default _default;
|
|
@@ -1,17 +1,5 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
3
|
-
import cmsConfig from "#cms-config";
|
|
4
|
-
import { useRuntimeConfig } from "#imports";
|
|
5
1
|
import { useDb } from "../utils/db-sqlite.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
}
|
|
16
|
-
migrate(useDb(), { migrationsFolder: migrationsDir });
|
|
2
|
+
import { runCmsMigrations } from "../utils/migrate.js";
|
|
3
|
+
export default async () => {
|
|
4
|
+
await runCmsMigrations(useDb());
|
|
17
5
|
};
|
|
@@ -1,7 +1,8 @@
|
|
|
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 { setUserSession, useRuntimeConfig } from "#imports";
|
|
4
|
+
import { setUserSession, useRuntimeConfig, useStorage } from "#imports";
|
|
5
|
+
import { assertSameOrigin } from "../../utils/require-admin.js";
|
|
5
6
|
const credentialsSchema = z.object({
|
|
6
7
|
email: z.string().trim().min(1),
|
|
7
8
|
password: z.string().min(1)
|
|
@@ -9,14 +10,37 @@ const credentialsSchema = z.object({
|
|
|
9
10
|
const RATE_WINDOW_MS = 15 * 6e4;
|
|
10
11
|
const RATE_MAX_FAILURES = 10;
|
|
11
12
|
const RATE_GLOBAL_MAX_FAILURES = 100;
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
let
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
const RATE_PRUNE_EVERY = 200;
|
|
14
|
+
const GLOBAL_KEY = "global";
|
|
15
|
+
let writesSincePrune = 0;
|
|
16
|
+
function rateStorage() {
|
|
17
|
+
return useStorage("cms:login-rate");
|
|
18
|
+
}
|
|
19
|
+
function rateKey(ip) {
|
|
20
|
+
return ip.replace(/[^a-z0-9]/gi, "-");
|
|
21
|
+
}
|
|
22
|
+
async function readEntry(key, now) {
|
|
23
|
+
const entry = await rateStorage().getItem(key);
|
|
24
|
+
return entry && entry.resetAt > now ? entry : null;
|
|
25
|
+
}
|
|
26
|
+
async function recordFailure(key, now) {
|
|
27
|
+
const current = await readEntry(key, now) ?? { count: 0, resetAt: now + RATE_WINDOW_MS };
|
|
28
|
+
current.count++;
|
|
29
|
+
await rateStorage().setItem(key, current, {
|
|
30
|
+
ttl: Math.ceil((current.resetAt - now) / 1e3)
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
async function prune(now) {
|
|
34
|
+
if (++writesSincePrune < RATE_PRUNE_EVERY) return;
|
|
35
|
+
writesSincePrune = 0;
|
|
36
|
+
const storage = rateStorage();
|
|
37
|
+
const keys = await storage.getKeys();
|
|
38
|
+
await Promise.all(
|
|
39
|
+
keys.map(async (key) => {
|
|
40
|
+
const entry = await storage.getItem(key);
|
|
41
|
+
if (!entry || entry.resetAt <= now) await storage.removeItem(key);
|
|
42
|
+
})
|
|
43
|
+
);
|
|
20
44
|
}
|
|
21
45
|
function safeEqual(a, b) {
|
|
22
46
|
const hashA = createHash("sha256").update(a).digest();
|
|
@@ -24,17 +48,14 @@ function safeEqual(a, b) {
|
|
|
24
48
|
return timingSafeEqual(hashA, hashB);
|
|
25
49
|
}
|
|
26
50
|
export default defineEventHandler(async (event) => {
|
|
27
|
-
|
|
51
|
+
assertSameOrigin(event);
|
|
52
|
+
const ip = rateKey(getRequestIP(event, { xForwardedFor: true }) ?? "unknown");
|
|
28
53
|
const now = Date.now();
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
const attempts = failures.get(ip);
|
|
37
|
-
if (attempts && attempts.resetAt > now && attempts.count >= RATE_MAX_FAILURES) {
|
|
54
|
+
const [attempts, globalFailures] = await Promise.all([
|
|
55
|
+
readEntry(ip, now),
|
|
56
|
+
readEntry(GLOBAL_KEY, now)
|
|
57
|
+
]);
|
|
58
|
+
if (globalFailures && globalFailures.count >= RATE_GLOBAL_MAX_FAILURES || attempts && attempts.count >= RATE_MAX_FAILURES) {
|
|
38
59
|
throw createError({
|
|
39
60
|
statusCode: 429,
|
|
40
61
|
statusMessage: "Too many failed attempts, try again later"
|
|
@@ -48,15 +69,10 @@ export default defineEventHandler(async (event) => {
|
|
|
48
69
|
const emailOk = safeEqual(body.email.toLowerCase(), adminEmail.toLowerCase());
|
|
49
70
|
const passwordOk = safeEqual(body.password, adminPassword);
|
|
50
71
|
if (!emailOk || !passwordOk) {
|
|
51
|
-
|
|
52
|
-
current.count++;
|
|
53
|
-
failures.set(ip, current);
|
|
54
|
-
if (globalFailures.resetAt <= now)
|
|
55
|
-
globalFailures = { count: 0, resetAt: now + RATE_WINDOW_MS };
|
|
56
|
-
globalFailures.count++;
|
|
72
|
+
await Promise.all([recordFailure(ip, now), recordFailure(GLOBAL_KEY, now), prune(now)]);
|
|
57
73
|
throw createError({ statusCode: 401, statusMessage: "Invalid credentials" });
|
|
58
74
|
}
|
|
59
|
-
|
|
75
|
+
await rateStorage().removeItem(ip);
|
|
60
76
|
await setUserSession(event, { user: { email: adminEmail.toLowerCase() } });
|
|
61
77
|
return { loggedIn: true };
|
|
62
78
|
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { drizzle } from 'drizzle-orm/d1';
|
|
2
|
+
export type D1Binding = Parameters<typeof drizzle>[0];
|
|
3
|
+
export declare function setD1Env(env: Record<string, unknown>): void;
|
|
4
|
+
export declare function useD1Binding(): D1Binding;
|
|
5
|
+
export declare function useDb(): import("drizzle-orm/d1").DrizzleD1Database<Record<string, unknown>> & {
|
|
6
|
+
$client: MiniflareD1Database;
|
|
7
|
+
};
|
|
8
|
+
type Db = ReturnType<typeof useDb>;
|
|
9
|
+
export type CmsDb = Db;
|
|
10
|
+
export declare function withTransaction<T>(fn: (db: CmsDb) => Promise<T>): Promise<T>;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/d1";
|
|
2
|
+
import { useRuntimeConfig } from "#imports";
|
|
3
|
+
let _env = null;
|
|
4
|
+
let _db = null;
|
|
5
|
+
export function setD1Env(env) {
|
|
6
|
+
if (_env === env) return;
|
|
7
|
+
_env = env;
|
|
8
|
+
_db = null;
|
|
9
|
+
}
|
|
10
|
+
export function useD1Binding() {
|
|
11
|
+
const { d1Binding } = useRuntimeConfig().cms;
|
|
12
|
+
const binding = _env?.[d1Binding];
|
|
13
|
+
if (!binding) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`[nuxt-cms] D1 binding '${d1Binding}' was not found on the Cloudflare environment. Declare it in wrangler.toml (or set cms.database.binding to the name you used).`
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
return binding;
|
|
19
|
+
}
|
|
20
|
+
export function useDb() {
|
|
21
|
+
if (!_db) _db = drizzle(useD1Binding());
|
|
22
|
+
return _db;
|
|
23
|
+
}
|
|
24
|
+
export function withTransaction(fn) {
|
|
25
|
+
return fn(useDb());
|
|
26
|
+
}
|
|
@@ -1,23 +1,22 @@
|
|
|
1
|
-
import { mkdirSync } from "node:fs";
|
|
2
1
|
import { createRequire } from "node:module";
|
|
3
|
-
import { dirname } from "node:path";
|
|
4
2
|
import { createClient } from "@libsql/client/web";
|
|
5
3
|
import { drizzle } from "drizzle-orm/libsql/web";
|
|
6
4
|
import { useRuntimeConfig } from "#imports";
|
|
7
5
|
let _db = null;
|
|
6
|
+
function createFileDb(url, dbPath, authToken) {
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const { mkdirSync } = require("node:fs");
|
|
9
|
+
const { dirname } = require("node:path");
|
|
10
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
11
|
+
const { createClient: createNativeClient } = require("@libsql/client");
|
|
12
|
+
const { drizzle: drizzleNative } = require("drizzle-orm/libsql");
|
|
13
|
+
return drizzleNative(createNativeClient({ url, authToken: authToken || void 0 }));
|
|
14
|
+
}
|
|
8
15
|
export function useDb() {
|
|
9
16
|
if (!_db) {
|
|
10
17
|
const { databaseUrl, databaseAuthToken, dbPath } = useRuntimeConfig().cms;
|
|
11
18
|
const url = databaseUrl || `file:${dbPath}`;
|
|
12
|
-
|
|
13
|
-
mkdirSync(dirname(dbPath), { recursive: true });
|
|
14
|
-
const require = createRequire(import.meta.url);
|
|
15
|
-
const { createClient: createNativeClient } = require("@libsql/client");
|
|
16
|
-
const { drizzle: drizzleNative } = require("drizzle-orm/libsql");
|
|
17
|
-
_db = drizzleNative(createNativeClient({ url, authToken: databaseAuthToken || void 0 }));
|
|
18
|
-
} else {
|
|
19
|
-
_db = drizzle(createClient({ url, authToken: databaseAuthToken || void 0 }));
|
|
20
|
-
}
|
|
19
|
+
_db = url.startsWith("file:") ? createFileDb(url, dbPath, databaseAuthToken) : drizzle(createClient({ url, authToken: databaseAuthToken || void 0 }));
|
|
21
20
|
}
|
|
22
21
|
return _db;
|
|
23
22
|
}
|
|
@@ -4,13 +4,15 @@ import { useRuntimeConfig } from "#imports";
|
|
|
4
4
|
let _db = null;
|
|
5
5
|
export function useDb() {
|
|
6
6
|
if (!_db) {
|
|
7
|
-
const { databaseUrl } = useRuntimeConfig().cms;
|
|
7
|
+
const { databaseUrl, poolMax } = useRuntimeConfig().cms;
|
|
8
8
|
if (!databaseUrl) {
|
|
9
9
|
throw new Error(
|
|
10
10
|
"[nuxt-cms] Missing Postgres connection string: set NUXT_CMS_DATABASE_URL or cms.database.url"
|
|
11
11
|
);
|
|
12
12
|
}
|
|
13
|
-
_db = drizzle(
|
|
13
|
+
_db = drizzle(
|
|
14
|
+
new pg.Pool({ connectionString: databaseUrl, ...poolMax > 0 ? { max: poolMax } : {} })
|
|
15
|
+
);
|
|
14
16
|
}
|
|
15
17
|
return _db;
|
|
16
18
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import cmsConfig from "#cms-config";
|
|
2
|
+
import { migrations as bundledMigrations } from "#cms-migrations";
|
|
3
|
+
import { useRuntimeConfig } from "#imports";
|
|
4
|
+
const MIGRATIONS_TABLE = "__drizzle_migrations";
|
|
5
|
+
async function readMigrations(migrationsDir) {
|
|
6
|
+
if (import.meta.dev) {
|
|
7
|
+
const { existsSync } = await import("node:fs");
|
|
8
|
+
if (!existsSync(`${migrationsDir}/meta/_journal.json`)) return [];
|
|
9
|
+
const { readMigrationFiles } = await import("drizzle-orm/migrator");
|
|
10
|
+
return readMigrationFiles({ migrationsFolder: migrationsDir });
|
|
11
|
+
}
|
|
12
|
+
return bundledMigrations;
|
|
13
|
+
}
|
|
14
|
+
async function pendingMigrations() {
|
|
15
|
+
const { migrationsDir, migrateOnBoot } = useRuntimeConfig().cms;
|
|
16
|
+
if (!migrateOnBoot) return [];
|
|
17
|
+
const migrations = await readMigrations(migrationsDir);
|
|
18
|
+
if (!migrations.length && Object.keys(cmsConfig).length) {
|
|
19
|
+
console.error(
|
|
20
|
+
`[nuxt-cms] No migrations were found for this build (expected in ${migrationsDir}). CMS tables may be missing \u2014 run the dev server once to generate them, commit server/db/migrations, and rebuild.`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return migrations;
|
|
24
|
+
}
|
|
25
|
+
export async function runCmsMigrations(db) {
|
|
26
|
+
const migrations = await pendingMigrations();
|
|
27
|
+
if (!migrations.length) return;
|
|
28
|
+
const { dialect, session } = db;
|
|
29
|
+
await dialect.migrate(migrations, session, {});
|
|
30
|
+
}
|
|
31
|
+
export async function runD1Migrations(binding) {
|
|
32
|
+
const migrations = await pendingMigrations();
|
|
33
|
+
if (!migrations.length) return;
|
|
34
|
+
const d1 = binding;
|
|
35
|
+
await d1.prepare(
|
|
36
|
+
`CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (id INTEGER PRIMARY KEY AUTOINCREMENT, hash text NOT NULL, created_at numeric)`
|
|
37
|
+
).run();
|
|
38
|
+
const { results } = await d1.prepare(`SELECT created_at FROM ${MIGRATIONS_TABLE} ORDER BY created_at DESC LIMIT 1`).all();
|
|
39
|
+
const lastApplied = results[0]?.created_at;
|
|
40
|
+
const batch = [];
|
|
41
|
+
for (const migration of migrations) {
|
|
42
|
+
if (lastApplied != null && Number(lastApplied) >= migration.folderMillis) continue;
|
|
43
|
+
for (const statement of migration.sql) {
|
|
44
|
+
if (statement.trim()) batch.push(d1.prepare(statement));
|
|
45
|
+
}
|
|
46
|
+
batch.push(
|
|
47
|
+
d1.prepare(`INSERT INTO ${MIGRATIONS_TABLE} ("hash", "created_at") VALUES (?, ?)`).bind(migration.hash, migration.folderMillis)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (batch.length) await d1.batch(batch);
|
|
51
|
+
}
|
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
import { createError, getRequestHeader, getRequestHost } from "h3";
|
|
2
2
|
import { getUserSession, useRuntimeConfig } from "#imports";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
if (!
|
|
6
|
-
let originHost;
|
|
3
|
+
const ORIGINLESS_SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
4
|
+
function hostOfUrl(value) {
|
|
5
|
+
if (!value) return void 0;
|
|
7
6
|
try {
|
|
8
|
-
|
|
7
|
+
const url = new URL(value);
|
|
8
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.host : void 0;
|
|
9
9
|
} catch {
|
|
10
|
-
|
|
10
|
+
return void 0;
|
|
11
11
|
}
|
|
12
|
-
|
|
13
|
-
|
|
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;
|
|
14
22
|
}
|
|
23
|
+
if (ORIGINLESS_SAFE_METHODS.has(event.method)) return;
|
|
24
|
+
if (hostOfUrl(getRequestHeader(event, "referer")) !== expectedHost) rejectCrossOrigin();
|
|
15
25
|
}
|
|
16
26
|
export async function requireAdmin(event) {
|
|
17
27
|
assertSameOrigin(event);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xleddyl/nuxt-cms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.38",
|
|
4
4
|
"description": "Lightweight CMS that ships with your Nuxt app: runs on the Nitro server, content types defined in code, /cms admin panel, GraphQL API, SQLite or Postgres. No external CMS needed!",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Edoardo Alberti (https://github.com/xleddyl)",
|
|
@@ -62,13 +62,11 @@
|
|
|
62
62
|
"dependencies": {
|
|
63
63
|
"@fontsource-variable/hanken-grotesk": "^5.3.0",
|
|
64
64
|
"@fontsource/fragment-mono": "^5.3.0",
|
|
65
|
-
"@libsql/client": "^0.17.4",
|
|
66
65
|
"@nuxt/kit": "^4.4.8",
|
|
67
66
|
"@tailwindcss/vite": "^4.3.2",
|
|
68
67
|
"@tiptap/starter-kit": "^3.0.0",
|
|
69
68
|
"@tiptap/vue-3": "^3.0.0",
|
|
70
69
|
"aws4fetch": "^1.0.20",
|
|
71
|
-
"better-sqlite3": "^12.2.0",
|
|
72
70
|
"drizzle-kit": "^0.31.0",
|
|
73
71
|
"drizzle-orm": "^0.44.0",
|
|
74
72
|
"gql.tada": "^1.11.2",
|
|
@@ -76,18 +74,38 @@
|
|
|
76
74
|
"graphql-yoga": "^5.10.0",
|
|
77
75
|
"jiti": "^2.7.0",
|
|
78
76
|
"nuxt-auth-utils": "^0.5.29",
|
|
79
|
-
"pg": "^8.16.0",
|
|
80
77
|
"tailwindcss": "^4.3.2",
|
|
81
78
|
"vite-svg-loader": "^5.1.0",
|
|
82
79
|
"zod": "^4.0.0"
|
|
83
80
|
},
|
|
81
|
+
"optionalDependencies": {
|
|
82
|
+
"better-sqlite3": "^12.2.0"
|
|
83
|
+
},
|
|
84
|
+
"peerDependencies": {
|
|
85
|
+
"@libsql/client": "^0.17.4",
|
|
86
|
+
"better-sqlite3": "^12.2.0",
|
|
87
|
+
"pg": "^8.16.0"
|
|
88
|
+
},
|
|
89
|
+
"peerDependenciesMeta": {
|
|
90
|
+
"@libsql/client": {
|
|
91
|
+
"optional": true
|
|
92
|
+
},
|
|
93
|
+
"better-sqlite3": {
|
|
94
|
+
"optional": true
|
|
95
|
+
},
|
|
96
|
+
"pg": {
|
|
97
|
+
"optional": true
|
|
98
|
+
}
|
|
99
|
+
},
|
|
84
100
|
"devDependencies": {
|
|
101
|
+
"@libsql/client": "^0.17.4",
|
|
85
102
|
"@nuxt/devtools": "^3.2.4",
|
|
86
103
|
"@nuxt/module-builder": "^1.0.2",
|
|
87
104
|
"@nuxt/schema": "^4.4.8",
|
|
88
105
|
"@types/better-sqlite3": "^7.6.13",
|
|
89
106
|
"@types/node": "latest",
|
|
90
107
|
"@types/pg": "^8.11.0",
|
|
108
|
+
"pg": "^8.16.0",
|
|
91
109
|
"changelogen": "^0.6.0",
|
|
92
110
|
"h3": "^1.15.11",
|
|
93
111
|
"nuxt": "^4.4.8",
|