@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 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 or libSQL/Turso**: a local file database by default, one config line to switch (including remote SQLite over the network).
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: '...' }` or
45
- `{ driver: 'libsql', url: '...', authToken: '...' }` (Turso/remote); see
46
- [Configuration](docs/configuration.md) for every option.
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 and libSQL/Turso drivers, migrations, studio.
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
3
  "configKey": "cms",
4
- "version": "0.1.36",
4
+ "version": "0.1.38",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
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, join, dirname } from 'node:path';
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 { driver: "postgres", path: "data/cms.db", url: database.url ?? "", authToken: "" };
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
- return { driver: "sqlite", path: database?.path ?? "data/cms.db", url: "", authToken: "" };
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,3 @@
1
+ import type { NitroApp } from 'nitropack/types';
2
+ declare const _default: (nitroApp: NitroApp) => void;
3
+ export default _default;
@@ -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
- const { migrationsDir } = useRuntimeConfig().cms;
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
- const { migrationsDir } = useRuntimeConfig().cms;
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
- export default () => {
7
- const { migrationsDir } = useRuntimeConfig().cms;
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
- 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 RATE_PRUNE_THRESHOLD = 1e3;
13
- const failures = /* @__PURE__ */ new Map();
14
- let globalFailures = { count: 0, resetAt: 0 };
15
- function pruneFailures(now) {
16
- if (failures.size < RATE_PRUNE_THRESHOLD) return;
17
- for (const [ip, entry] of failures) {
18
- if (entry.resetAt <= now) failures.delete(ip);
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
- const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown";
51
+ assertSameOrigin(event);
52
+ const ip = rateKey(getRequestIP(event, { xForwardedFor: true }) ?? "unknown");
28
53
  const now = Date.now();
29
- pruneFailures(now);
30
- if (globalFailures.resetAt > now && globalFailures.count >= RATE_GLOBAL_MAX_FAILURES) {
31
- throw createError({
32
- statusCode: 429,
33
- statusMessage: "Too many failed attempts, try again later"
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
- const current = attempts && attempts.resetAt > now ? attempts : { count: 0, resetAt: now + RATE_WINDOW_MS };
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
- failures.delete(ip);
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
- if (url.startsWith("file:")) {
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(new pg.Pool({ connectionString: databaseUrl }));
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,8 @@
1
+ export interface CmsMigration {
2
+ sql: string[];
3
+ bps: boolean;
4
+ folderMillis: number;
5
+ hash: string;
6
+ }
7
+ export declare function runCmsMigrations(db: unknown): Promise<void>;
8
+ export declare function runD1Migrations(binding: unknown): Promise<void>;
@@ -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,2 +1,3 @@
1
1
  import type { H3Event } from 'h3';
2
+ export declare function assertSameOrigin(event: H3Event): void;
2
3
  export declare function requireAdmin(event: H3Event): Promise<void>;
@@ -1,17 +1,27 @@
1
1
  import { createError, getRequestHeader, getRequestHost } from "h3";
2
2
  import { getUserSession, useRuntimeConfig } from "#imports";
3
- function assertSameOrigin(event) {
4
- const origin = getRequestHeader(event, "origin");
5
- if (!origin) return;
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
- originHost = new URL(origin).host;
7
+ const url = new URL(value);
8
+ return url.protocol === "http:" || url.protocol === "https:" ? url.host : void 0;
9
9
  } catch {
10
- originHost = void 0;
10
+ return void 0;
11
11
  }
12
- if (!originHost || originHost !== getRequestHost(event, { xForwardedHost: true })) {
13
- throw createError({ statusCode: 403, statusMessage: "Cross-origin request rejected" });
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.36",
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",