@xleddyl/nuxt-cms 0.1.57 → 0.1.59

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
@@ -8,7 +8,7 @@ nuxt-cms is a Nuxt module that leverages the Nitro server to ship a lightweight
8
8
 
9
9
  - **Zero extra infrastructure**: the CMS runs inside your app's Nitro server; you deploy one thing.
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
- - **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.
11
+ - **Admin panel at `/cms`**: entry editing with validation, drafts, media library (S3-compatible storage, a directory on the server's disk, 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
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
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.
@@ -89,6 +89,7 @@ Every secret maps to runtime config, so it can be set as an env var instead of i
89
89
  | `NUXT_CMS_MEDIA_ACCESS_KEY_ID` | for media | S3 access key id |
90
90
  | `NUXT_CMS_MEDIA_SECRET_ACCESS_KEY` | for media | S3 secret access key |
91
91
  | `NUXT_PUBLIC_CMS_MEDIA_BASE_URL` | for media | public base URL for uploaded files |
92
+ | `NUXT_CMS_MEDIA_DIR` | for filesystem media | directory for uploaded files (default `data/media`) |
92
93
 
93
94
  ## Documentation
94
95
 
@@ -100,7 +101,7 @@ Full documentation lives in [`docs/`](docs/README.md):
100
101
  - [Schema](docs/schema.md) — `defineCmsConfig`, entries, pages, field types, relations, blocks, i18n.
101
102
  - [Querying content](docs/querying.md) — GraphQL API, `useCmsSingle` / `useCmsCollection` / `useCmsPage` / `useCms` / `$cmsQuery`, filters, sorting, pagination.
102
103
  - [Admin panel & security](docs/admin.md) — pages, authentication, sessions, admin REST API.
103
- - [Media](docs/media.md) — S3-compatible storage or local mode backed by your `public/` folder, upload flow, allowed file types.
104
+ - [Media](docs/media.md) — S3-compatible storage, filesystem storage on the server's disk, or local mode backed by your `public/` folder, upload flow, allowed file types.
104
105
  - [Deployment](docs/deployment.md) — host/driver matrix, migrations on serverless, horizontal scaling, Cloudflare Workers.
105
106
 
106
107
  ## LLM guide
package/dist/module.d.mts CHANGED
@@ -30,6 +30,11 @@ type ModuleOptionsMedia = {
30
30
  } | {
31
31
  storage: 'local';
32
32
  publicBaseUrl: string;
33
+ } | {
34
+ storage: 'filesystem';
35
+ dir?: string;
36
+ publicBaseUrl?: string;
37
+ maxFileSize?: number;
33
38
  };
34
39
  interface ModuleOptions {
35
40
  enabled?: boolean;
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.57",
4
+ "version": "0.1.59",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -885,6 +885,8 @@ function resolveDatabaseOptions(database) {
885
885
  }
886
886
  return { ...shared, driver: "sqlite", path: database?.path ?? "data/cms.db" };
887
887
  }
888
+ const DEFAULT_FILESYSTEM_MEDIA_DIR = "data/media";
889
+ const DEFAULT_FILESYSTEM_MEDIA_BASE_URL = "/media";
888
890
  function resolveMediaOptions(media) {
889
891
  if (media?.storage === "local") {
890
892
  return {
@@ -896,7 +898,22 @@ function resolveMediaOptions(media) {
896
898
  secretAccessKey: "",
897
899
  presignExpiry: 600,
898
900
  maxFileSize: DEFAULT_MEDIA_MAX_FILE_SIZE,
899
- publicBaseUrl: media.publicBaseUrl
901
+ publicBaseUrl: media.publicBaseUrl,
902
+ dir: ""
903
+ };
904
+ }
905
+ if (media?.storage === "filesystem") {
906
+ return {
907
+ storage: "filesystem",
908
+ endpoint: "",
909
+ region: "auto",
910
+ bucket: "",
911
+ accessKeyId: "",
912
+ secretAccessKey: "",
913
+ presignExpiry: 600,
914
+ maxFileSize: media.maxFileSize ?? DEFAULT_MEDIA_MAX_FILE_SIZE,
915
+ publicBaseUrl: media.publicBaseUrl || DEFAULT_FILESYSTEM_MEDIA_BASE_URL,
916
+ dir: media.dir || DEFAULT_FILESYSTEM_MEDIA_DIR
900
917
  };
901
918
  }
902
919
  return {
@@ -908,7 +925,8 @@ function resolveMediaOptions(media) {
908
925
  secretAccessKey: media?.secretAccessKey ?? "",
909
926
  presignExpiry: media?.presignExpiry ?? 600,
910
927
  maxFileSize: media?.maxFileSize ?? DEFAULT_MEDIA_MAX_FILE_SIZE,
911
- publicBaseUrl: media?.publicBaseUrl ?? ""
928
+ publicBaseUrl: media?.publicBaseUrl ?? "",
929
+ dir: ""
912
930
  };
913
931
  }
914
932
  function resolveModuleOptions(options) {
@@ -1297,6 +1315,18 @@ const module$1 = defineNuxtModule({
1297
1315
  logger.error(`[nuxt-cms] drizzle-kit generate failed (exit code ${code})`);
1298
1316
  });
1299
1317
  }
1318
+ const mediaFilesystemBase = resolved.media.storage === "filesystem" ? `/${resolved.media.publicBaseUrl.split("/").filter(Boolean).join("/")}` : "";
1319
+ if (resolved.media.storage === "filesystem" && !resolved.media.publicBaseUrl.startsWith("/")) {
1320
+ throw new Error(
1321
+ `[nuxt-cms] media.storage is 'filesystem' but media.publicBaseUrl (${resolved.media.publicBaseUrl || "empty"}) is not a root-relative path. Set it to something like '/media': the module serves the uploaded files there.`
1322
+ );
1323
+ }
1324
+ if (mediaFilesystemBase === "/" || mediaFilesystemBase.startsWith("/api/")) {
1325
+ throw new Error(
1326
+ `[nuxt-cms] media.publicBaseUrl (${resolved.media.publicBaseUrl}) cannot serve the uploaded files: pick a dedicated path such as '/media'.`
1327
+ );
1328
+ }
1329
+ const mediaDir = resolved.media.storage === "filesystem" ? isAbsolute(resolved.media.dir) ? resolved.media.dir : resolve(nuxt.options.rootDir, resolved.media.dir) : "";
1300
1330
  const existingConfig = nuxt.options.runtimeConfig.cms ?? {};
1301
1331
  nuxt.options.runtimeConfig.cms = {
1302
1332
  adminEmail: resolved.admin.email,
@@ -1324,6 +1354,7 @@ const module$1 = defineNuxtModule({
1324
1354
  accessKeyId: resolved.media.accessKeyId,
1325
1355
  secretAccessKey: resolved.media.secretAccessKey,
1326
1356
  localRoot: mediaLocalRoot,
1357
+ dir: mediaDir,
1327
1358
  ...existingConfig.media ?? {}
1328
1359
  }
1329
1360
  };
@@ -1454,6 +1485,17 @@ const module$1 = defineNuxtModule({
1454
1485
  method: "delete",
1455
1486
  handler: resolver.resolve("./runtime/server/api/media-folder.delete")
1456
1487
  });
1488
+ if (mediaFilesystemBase) {
1489
+ addServerHandler({
1490
+ route: "/api/cms/admin/media/upload",
1491
+ method: "put",
1492
+ handler: resolver.resolve("./runtime/server/api/media-upload.put")
1493
+ });
1494
+ addServerHandler({
1495
+ route: `${mediaFilesystemBase}/**`,
1496
+ handler: resolver.resolve("./runtime/server/routes/media-file")
1497
+ });
1498
+ }
1457
1499
  const api = "/api/cms/admin/:collection";
1458
1500
  addServerHandler({
1459
1501
  route: api,
@@ -3,6 +3,7 @@ import { dirname, isAbsolute, resolve } from "node:path";
3
3
  import { migrationsDirFor } from "./shared/migrations-dir.js";
4
4
  import { resolvePageRoutes, routePathsFromDir } from "./shared/page-routes.js";
5
5
  import { customId, ID_LENGTH } from "./server/utils/custom-id.js";
6
+ import { applyLibsqlMigrations } from "./server/utils/libsql-migrations.js";
6
7
  export { customId, ID_LENGTH };
7
8
  export function resolveCmsPages(config, options = {}) {
8
9
  const root = options.root ?? process.cwd();
@@ -27,7 +28,7 @@ export async function createCmsSeeder(opts = {}) {
27
28
  const resolvedDbPath = isAbsolute(dbPath) ? dbPath : resolve(root, dbPath);
28
29
  const migrationsFolder = opts.migrationsDir ?? migrationsDirFor(root, driver);
29
30
  if (driver === "libsql") {
30
- const { migrate: migrate2 } = await import("drizzle-orm/libsql/migrator");
31
+ const { readMigrationFiles } = await import("drizzle-orm/migrator");
31
32
  const dbUrl = url || `file:${resolvedDbPath}`;
32
33
  let db2;
33
34
  if (dbUrl.startsWith("file:")) {
@@ -40,7 +41,13 @@ export async function createCmsSeeder(opts = {}) {
40
41
  const { drizzle: drizzle2 } = await import("drizzle-orm/libsql/web");
41
42
  db2 = drizzle2(createClient({ url: dbUrl, authToken: authToken || void 0 }));
42
43
  }
43
- return { db: db2, migrate: () => migrate2(db2, { migrationsFolder }) };
44
+ const client = db2.$client;
45
+ return {
46
+ db: db2,
47
+ migrate: async () => {
48
+ await applyLibsqlMigrations(client, readMigrationFiles({ migrationsFolder }));
49
+ }
50
+ };
44
51
  }
45
52
  if (driver === "postgres") {
46
53
  if (!url) throw new Error("[nuxt-cms] seed: Postgres needs a url (NUXT_CMS_DATABASE_URL)");
@@ -4,7 +4,7 @@ import { z } from "zod";
4
4
  import { useDb } from "#cms-db";
5
5
  import { cms_media } from "#cms-tables";
6
6
  import { isMediaFolderMarker, normalizeMediaFolder } from "../../shared/index.js";
7
- import { encodeKey, useMediaStorage } from "../utils/media.js";
7
+ import { useMediaStorage } from "../utils/media.js";
8
8
  import { requireAdmin } from "../utils/require-admin.js";
9
9
  const querySchema = z.object({
10
10
  name: z.string().min(1).max(255),
@@ -12,7 +12,7 @@ const querySchema = z.object({
12
12
  });
13
13
  export default defineEventHandler(async (event) => {
14
14
  await requireAdmin(event);
15
- const { client, bucketUrl } = useMediaStorage(event);
15
+ const { store } = useMediaStorage(event);
16
16
  const { name, recursive } = await getValidatedQuery(event, querySchema.parse);
17
17
  const folder = normalizeMediaFolder(name);
18
18
  if (!folder) {
@@ -28,13 +28,7 @@ export default defineEventHandler(async (event) => {
28
28
  });
29
29
  }
30
30
  for (const row of rows) {
31
- const res = await client.fetch(`${bucketUrl}/${encodeKey(row.key)}`, { method: "DELETE" });
32
- if (!res.ok && res.status !== 404) {
33
- throw createError({
34
- statusCode: 502,
35
- statusMessage: `Bucket delete failed (${res.status})`
36
- });
37
- }
31
+ await store.remove(row.key);
38
32
  }
39
33
  if (rows.length) {
40
34
  await db.delete(cms_media).where(
@@ -3,26 +3,19 @@ import { z } from "zod";
3
3
  import { useDb } from "#cms-db";
4
4
  import { cms_media } from "#cms-tables";
5
5
  import { mediaFolderMarkerKey, normalizeMediaFolder } from "../../shared/index.js";
6
- import { encodeKey, useMediaStorage } from "../utils/media.js";
6
+ import { useMediaStorage } from "../utils/media.js";
7
7
  import { requireAdmin } from "../utils/require-admin.js";
8
8
  const bodySchema = z.object({ name: z.string().min(1).max(255) });
9
9
  export default defineEventHandler(async (event) => {
10
10
  await requireAdmin(event);
11
- const { client, bucketUrl } = useMediaStorage(event);
11
+ const { store } = useMediaStorage(event);
12
12
  const { name } = await readValidatedBody(event, bodySchema.parse);
13
13
  const folder = normalizeMediaFolder(name);
14
14
  if (!folder) {
15
15
  throw createError({ statusCode: 400, statusMessage: "Invalid folder name" });
16
16
  }
17
17
  const key = mediaFolderMarkerKey(folder);
18
- const res = await client.fetch(`${bucketUrl}/${encodeKey(key)}`, {
19
- method: "PUT",
20
- body: new Uint8Array(),
21
- headers: { "content-type": "application/x-empty", "content-length": "0" }
22
- });
23
- if (!res.ok) {
24
- throw createError({ statusCode: 502, statusMessage: `Bucket write failed (${res.status})` });
25
- }
18
+ await store.write(key, new Uint8Array(), "application/x-empty");
26
19
  const values = {
27
20
  key,
28
21
  mime: "application/x-empty",
@@ -3,9 +3,7 @@ declare const _default: import("h3").EventHandler<import("h3").EventHandlerReque
3
3
  folder: string | null;
4
4
  uploadUrl: string;
5
5
  method: string;
6
- headers: {
7
- 'content-type': string;
8
- };
6
+ headers: Record<string, string>;
9
7
  publicUrl: string | null;
10
8
  expiresIn: number;
11
9
  }>>;
@@ -20,7 +20,7 @@ function slugifyFilename(filename) {
20
20
  }
21
21
  export default defineEventHandler(async (event) => {
22
22
  await requireAdmin(event);
23
- const { media, client, bucketUrl, publicUrl } = useMediaStorage(event);
23
+ const { media, store, publicUrl } = useMediaStorage(event);
24
24
  const { filename, contentType, size, folder } = await readValidatedBody(event, bodySchema.parse);
25
25
  assertUploadContentType(contentType);
26
26
  assertUploadSize(size, media.maxFileSize);
@@ -28,21 +28,13 @@ export default defineEventHandler(async (event) => {
28
28
  const normalizedFolder = normalizeMediaFolder(folder);
29
29
  const prefix = normalizedFolder ?? `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
30
30
  const key = `${prefix}/${randomUUID()}-${slugifyFilename(filename)}`;
31
- const url = new URL(`${bucketUrl}/${key}`);
32
- url.searchParams.set("X-Amz-Expires", String(media.presignExpiry));
33
- const signed = await client.sign(
34
- new Request(url, {
35
- method: "PUT",
36
- headers: { "content-type": contentType, "content-length": String(size) }
37
- }),
38
- { aws: { signQuery: true, allHeaders: true } }
39
- );
31
+ const target = await store.uploadTarget(key, contentType, size);
40
32
  return {
41
33
  key,
42
34
  folder: normalizedFolder,
43
- uploadUrl: signed.url,
35
+ uploadUrl: target.url,
44
36
  method: "PUT",
45
- headers: { "content-type": contentType },
37
+ headers: target.headers,
46
38
  publicUrl: publicUrl(key),
47
39
  expiresIn: media.presignExpiry
48
40
  };
@@ -0,0 +1,2 @@
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<void>>;
2
+ export default _default;
@@ -0,0 +1,42 @@
1
+ import { existsSync } from "node:fs";
2
+ import {
3
+ createError,
4
+ defineEventHandler,
5
+ getRequestHeader,
6
+ getRequestWebStream,
7
+ getValidatedQuery,
8
+ sendNoContent
9
+ } from "h3";
10
+ import { z } from "zod";
11
+ import { isMediaFolderMarker } from "../../shared/index.js";
12
+ import { objectKeySchema } from "../../shared/validation.js";
13
+ import { requireMediaFilePath, writeMediaFile } from "../utils/media-fs.js";
14
+ import { assertUploadContentType, assertUploadSize, useMediaStorage } from "../utils/media.js";
15
+ import { requireAdmin } from "../utils/require-admin.js";
16
+ const querySchema = z.object({ key: objectKeySchema });
17
+ export default defineEventHandler(async (event) => {
18
+ await requireAdmin(event);
19
+ const { media } = useMediaStorage(event);
20
+ if (media.storage !== "filesystem") {
21
+ throw createError({
22
+ statusCode: 501,
23
+ statusMessage: "Direct uploads are only available with filesystem media storage"
24
+ });
25
+ }
26
+ const { key } = await getValidatedQuery(event, querySchema.parse);
27
+ if (isMediaFolderMarker(key)) {
28
+ throw createError({ statusCode: 400, statusMessage: "Invalid object key" });
29
+ }
30
+ assertUploadContentType(getRequestHeader(event, "content-type") ?? "");
31
+ const declaredSize = Number(getRequestHeader(event, "content-length"));
32
+ if (Number.isFinite(declaredSize)) assertUploadSize(declaredSize, media.maxFileSize);
33
+ if (existsSync(requireMediaFilePath(media.dir, key))) {
34
+ throw createError({ statusCode: 409, statusMessage: "A file with this key already exists" });
35
+ }
36
+ const body = getRequestWebStream(event);
37
+ if (!body) {
38
+ throw createError({ statusCode: 400, statusMessage: "Empty upload" });
39
+ }
40
+ await writeMediaFile(media.dir, key, body, media.maxFileSize);
41
+ return sendNoContent(event, 201);
42
+ });
@@ -4,22 +4,19 @@ import { z } from "zod";
4
4
  import { useDb } from "#cms-db";
5
5
  import { cms_media } from "#cms-tables";
6
6
  import { objectKeySchema } from "../../shared/validation.js";
7
- import { encodeKey, useMediaStorage } from "../utils/media.js";
7
+ import { useMediaStorage } from "../utils/media.js";
8
8
  import { requireAdmin } from "../utils/require-admin.js";
9
9
  const querySchema = z.object({ key: objectKeySchema });
10
10
  export default defineEventHandler(async (event) => {
11
11
  await requireAdmin(event);
12
- const { client, bucketUrl } = useMediaStorage(event);
12
+ const { store } = useMediaStorage(event);
13
13
  const { key } = await getValidatedQuery(event, querySchema.parse);
14
14
  const db = useDb();
15
15
  const [row] = await db.select().from(cms_media).where(eq(cms_media.key, key)).limit(1);
16
16
  if (!row) {
17
17
  throw createError({ statusCode: 404, statusMessage: "Media not found" });
18
18
  }
19
- const res = await client.fetch(`${bucketUrl}/${encodeKey(key)}`, { method: "DELETE" });
20
- if (!res.ok && res.status !== 404) {
21
- throw createError({ statusCode: 502, statusMessage: `Bucket delete failed (${res.status})` });
22
- }
19
+ await store.remove(key);
23
20
  await db.delete(cms_media).where(eq(cms_media.key, key));
24
21
  return { ok: true };
25
22
  });
@@ -1,9 +1,11 @@
1
- import { defineEventHandler, readValidatedBody } from "h3";
1
+ import { existsSync } from "node:fs";
2
+ import { createError, defineEventHandler, readValidatedBody } from "h3";
2
3
  import { z } from "zod";
3
4
  import { useDb } from "#cms-db";
4
5
  import { cms_media } from "#cms-tables";
5
6
  import { normalizeMediaFolder } from "../../shared/index.js";
6
7
  import { objectKeySchema } from "../../shared/validation.js";
8
+ import { requireMediaFilePath } from "../utils/media-fs.js";
7
9
  import {
8
10
  assertMediaWritable,
9
11
  assertUploadContentType,
@@ -26,6 +28,9 @@ export default defineEventHandler(async (event) => {
26
28
  assertMediaWritable(media);
27
29
  const body = await readValidatedBody(event, bodySchema.parse);
28
30
  if (body.mime) assertUploadContentType(body.mime);
31
+ if (media.storage === "filesystem" && !existsSync(requireMediaFilePath(media.dir, body.key))) {
32
+ throw createError({ statusCode: 400, statusMessage: "The uploaded file was not found" });
33
+ }
29
34
  const values = {
30
35
  key: body.key,
31
36
  mime: body.mime ?? null,
@@ -1,5 +1,5 @@
1
1
  import { useDb } from "../utils/db-libsql.js";
2
- import { runCmsMigrations } from "../utils/migrate.js";
2
+ import { runLibsqlMigrations } from "../utils/migrate.js";
3
3
  export default async () => {
4
- await runCmsMigrations(useDb());
4
+ await runLibsqlMigrations(useDb());
5
5
  };
@@ -0,0 +1,2 @@
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<void | null>>;
2
+ export default _default;
@@ -0,0 +1,81 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { stat } from "node:fs/promises";
3
+ import { eq } from "drizzle-orm";
4
+ import {
5
+ createError,
6
+ defineEventHandler,
7
+ getRequestHeader,
8
+ getRouterParam,
9
+ sendStream,
10
+ setResponseHeaders,
11
+ setResponseStatus
12
+ } from "h3";
13
+ import { useDb } from "#cms-db";
14
+ import { cms_media } from "#cms-tables";
15
+ import { useRuntimeConfig } from "#imports";
16
+ import { isMediaFolderMarker } from "../../shared/index.js";
17
+ import { mediaFilePath, parseByteRange } from "../utils/media-fs.js";
18
+ import { isAllowedUploadType } from "../utils/media.js";
19
+ import { mediaMimeForKey } from "../utils/media-sync.js";
20
+ const IMMUTABLE = "public, max-age=31536000, immutable";
21
+ function notFound() {
22
+ throw createError({ statusCode: 404, statusMessage: "Not found" });
23
+ }
24
+ function endWithoutBody(event) {
25
+ event.node.res.end();
26
+ }
27
+ async function mimeFor(key) {
28
+ const [row] = await useDb().select({ mime: cms_media.mime }).from(cms_media).where(eq(cms_media.key, key)).limit(1);
29
+ const mime = row?.mime || mediaMimeForKey(key);
30
+ return mime && isAllowedUploadType(mime) ? mime : null;
31
+ }
32
+ export default defineEventHandler(async (event) => {
33
+ if (event.method !== "GET" && event.method !== "HEAD") {
34
+ throw createError({ statusCode: 405, statusMessage: "Method not allowed" });
35
+ }
36
+ const { dir } = useRuntimeConfig(event).cms.media;
37
+ const raw = getRouterParam(event, "_") ?? "";
38
+ let key;
39
+ try {
40
+ key = decodeURIComponent(raw);
41
+ } catch {
42
+ notFound();
43
+ }
44
+ if (isMediaFolderMarker(key)) notFound();
45
+ const path = mediaFilePath(dir, key);
46
+ if (!path) notFound();
47
+ const info = await stat(path).catch(() => null);
48
+ if (!info?.isFile()) notFound();
49
+ const mime = await mimeFor(key);
50
+ const etag = `"${info.size.toString(16)}-${Math.floor(info.mtimeMs).toString(16)}"`;
51
+ setResponseHeaders(event, {
52
+ "content-type": mime ?? "application/octet-stream",
53
+ ...mime ? {} : { "content-disposition": "attachment" },
54
+ "cache-control": IMMUTABLE,
55
+ "last-modified": info.mtime.toUTCString(),
56
+ etag,
57
+ "accept-ranges": "bytes",
58
+ "x-content-type-options": "nosniff"
59
+ });
60
+ if (getRequestHeader(event, "if-none-match") === etag) {
61
+ setResponseStatus(event, 304);
62
+ return null;
63
+ }
64
+ const range = parseByteRange(getRequestHeader(event, "range"), info.size);
65
+ if (range === "unsatisfiable") {
66
+ setResponseHeaders(event, { "content-range": `bytes */${info.size}` });
67
+ throw createError({ statusCode: 416, statusMessage: "Range not satisfiable" });
68
+ }
69
+ if (range) {
70
+ setResponseStatus(event, 206);
71
+ setResponseHeaders(event, {
72
+ "content-range": `bytes ${range.start}-${range.end}/${info.size}`,
73
+ "content-length": range.end - range.start + 1
74
+ });
75
+ if (event.method === "HEAD") return endWithoutBody(event);
76
+ return sendStream(event, createReadStream(path, { start: range.start, end: range.end }));
77
+ }
78
+ setResponseHeaders(event, { "content-length": info.size });
79
+ if (event.method === "HEAD") return endWithoutBody(event);
80
+ return sendStream(event, createReadStream(path));
81
+ });
@@ -0,0 +1,16 @@
1
+ export interface LibsqlMigration {
2
+ sql: string[];
3
+ folderMillis: number;
4
+ hash: string;
5
+ }
6
+ export interface LibsqlStatement {
7
+ sql: string;
8
+ args: (string | number)[];
9
+ }
10
+ export interface LibsqlMigrationClient {
11
+ execute: (statement: string) => Promise<{
12
+ rows: ArrayLike<unknown>[];
13
+ }>;
14
+ batch: (statements: LibsqlStatement[], mode: 'write') => Promise<unknown>;
15
+ }
16
+ export declare function applyLibsqlMigrations(client: LibsqlMigrationClient, migrations: LibsqlMigration[]): Promise<number>;
@@ -0,0 +1,35 @@
1
+ const MIGRATIONS_TABLE = "__drizzle_migrations";
2
+ async function lastAppliedMillis(client) {
3
+ const { rows } = await client.execute(
4
+ `SELECT id, hash, created_at FROM "${MIGRATIONS_TABLE}" ORDER BY created_at DESC LIMIT 1`
5
+ );
6
+ const createdAt = rows[0]?.[2];
7
+ return createdAt == null ? null : Number(createdAt);
8
+ }
9
+ export async function applyLibsqlMigrations(client, migrations) {
10
+ if (!migrations.length) return 0;
11
+ await client.execute(
12
+ `CREATE TABLE IF NOT EXISTS "${MIGRATIONS_TABLE}" (id SERIAL PRIMARY KEY, hash text NOT NULL, created_at numeric)`
13
+ );
14
+ const lastApplied = await lastAppliedMillis(client);
15
+ const pending = migrations.filter(
16
+ (migration) => lastApplied == null || lastApplied < migration.folderMillis
17
+ );
18
+ if (!pending.length) return 0;
19
+ const statements = pending.flatMap((migration) => [
20
+ ...migration.sql.filter((statement) => statement.trim()).map((sql) => ({ sql, args: [] })),
21
+ {
22
+ sql: `INSERT INTO "${MIGRATIONS_TABLE}" ("hash", "created_at") VALUES (?, ?)`,
23
+ args: [migration.hash, migration.folderMillis]
24
+ }
25
+ ]);
26
+ try {
27
+ await client.batch(statements, "write");
28
+ } catch (error) {
29
+ const latest = Math.max(...migrations.map((migration) => migration.folderMillis));
30
+ const appliedNow = await lastAppliedMillis(client).catch(() => null);
31
+ if (appliedNow != null && appliedNow >= latest) return 0;
32
+ throw error;
33
+ }
34
+ return pending.length;
35
+ }
@@ -0,0 +1,9 @@
1
+ export declare function mediaFilePath(dir: string, key: string): string | null;
2
+ export declare function requireMediaFilePath(dir: string, key: string): string;
3
+ export type ByteRange = {
4
+ start: number;
5
+ end: number;
6
+ } | 'unsatisfiable' | null;
7
+ export declare function parseByteRange(header: string | undefined, size: number): ByteRange;
8
+ export declare function writeMediaFile(dir: string, key: string, body: ReadableStream<Uint8Array> | Uint8Array, maxBytes: number): Promise<void>;
9
+ export declare function removeMediaFile(dir: string, key: string): Promise<void>;
@@ -0,0 +1,89 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createWriteStream } from "node:fs";
3
+ import { mkdir, rename, rm, rmdir, unlink } from "node:fs/promises";
4
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
+ import { Readable, Transform } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
7
+ import { createError } from "h3";
8
+ import { formatFileSize } from "../../shared/index.js";
9
+ export function mediaFilePath(dir, key) {
10
+ if (!dir || !key || key.startsWith("/") || /[\0\\]/.test(key)) return null;
11
+ const root = resolve(dir);
12
+ const target = resolve(root, ...key.split("/"));
13
+ const rel = relative(root, target);
14
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) return null;
15
+ return target;
16
+ }
17
+ export function requireMediaFilePath(dir, key) {
18
+ const path = mediaFilePath(dir, key);
19
+ if (!path) {
20
+ throw createError({ statusCode: 400, statusMessage: "Invalid object key" });
21
+ }
22
+ return path;
23
+ }
24
+ export function parseByteRange(header, size) {
25
+ if (!header) return null;
26
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
27
+ if (!match || !match[1] && !match[2]) return null;
28
+ if (size === 0) return "unsatisfiable";
29
+ if (!match[1]) {
30
+ const suffix = Number(match[2]);
31
+ if (suffix === 0) return "unsatisfiable";
32
+ return { start: Math.max(0, size - suffix), end: size - 1 };
33
+ }
34
+ const start = Number(match[1]);
35
+ const end = match[2] ? Math.min(Number(match[2]), size - 1) : size - 1;
36
+ if (start >= size || end < start) return "unsatisfiable";
37
+ return { start, end };
38
+ }
39
+ function sizeLimit(maxBytes) {
40
+ let received = 0;
41
+ return new Transform({
42
+ transform(chunk, _encoding, callback) {
43
+ received += chunk.length;
44
+ if (received > maxBytes) {
45
+ callback(
46
+ createError({
47
+ statusCode: 413,
48
+ statusMessage: `File exceeds the maximum size of ${formatFileSize(maxBytes)}`
49
+ })
50
+ );
51
+ return;
52
+ }
53
+ callback(null, chunk);
54
+ }
55
+ });
56
+ }
57
+ export async function writeMediaFile(dir, key, body, maxBytes) {
58
+ const target = requireMediaFilePath(dir, key);
59
+ await mkdir(dirname(target), { recursive: true });
60
+ const temp = `${target}.${randomUUID()}.upload`;
61
+ const source = body instanceof Uint8Array ? Readable.from([Buffer.from(body)]) : Readable.fromWeb(body);
62
+ try {
63
+ await pipeline(source, sizeLimit(maxBytes), createWriteStream(temp, { flags: "wx" }));
64
+ await rename(temp, target);
65
+ } catch (error) {
66
+ await rm(temp, { force: true });
67
+ throw error;
68
+ }
69
+ }
70
+ export async function removeMediaFile(dir, key) {
71
+ const target = requireMediaFilePath(dir, key);
72
+ try {
73
+ await unlink(target);
74
+ } catch (error) {
75
+ if (error.code !== "ENOENT") throw error;
76
+ }
77
+ await pruneEmptyDirectories(resolve(dir), dirname(target));
78
+ }
79
+ async function pruneEmptyDirectories(root, from) {
80
+ let current = from;
81
+ while (current !== root && current.startsWith(`${root}${sep}`)) {
82
+ try {
83
+ await rmdir(current);
84
+ } catch {
85
+ return;
86
+ }
87
+ current = dirname(current);
88
+ }
89
+ }
@@ -1,5 +1,4 @@
1
1
  import type { H3Event } from 'h3';
2
- import { AwsClient } from 'aws4fetch';
3
2
  import type { MediaItem, MediaStorageMode } from '../../shared/index.js';
4
3
  interface MediaConfig {
5
4
  storage: MediaStorageMode;
@@ -10,7 +9,18 @@ interface MediaConfig {
10
9
  maxFileSize: number;
11
10
  accessKeyId: string;
12
11
  secretAccessKey: string;
12
+ dir?: string;
13
13
  }
14
+ export interface MediaUploadTarget {
15
+ url: string;
16
+ headers: Record<string, string>;
17
+ }
18
+ export interface MediaStore {
19
+ uploadTarget: (key: string, contentType: string, size: number) => Promise<MediaUploadTarget>;
20
+ write: (key: string, body: Uint8Array<ArrayBuffer>, contentType: string) => Promise<void>;
21
+ remove: (key: string) => Promise<void>;
22
+ }
23
+ export declare function isAllowedUploadType(contentType: string): boolean;
14
24
  export declare function assertUploadContentType(contentType: string): void;
15
25
  export declare function assertUploadSize(size: number, maxFileSize: number): void;
16
26
  export declare function encodeKey(key: string): string;
@@ -20,10 +30,10 @@ export declare function useMediaConfig(event: H3Event): {
20
30
  media: MediaConfig;
21
31
  publicUrl: (key: string) => string | null;
22
32
  };
33
+ export declare function mediaUploadPath(key: string): string;
23
34
  export declare function useMediaStorage(event: H3Event): {
24
35
  media: MediaConfig;
25
- client: AwsClient;
26
- bucketUrl: string;
36
+ store: MediaStore;
27
37
  publicUrl: (key: string) => string | null;
28
38
  };
29
39
  export declare function toMediaItem(row: Omit<MediaItem, 'type' | 'url'>, publicUrl: (key: string) => string | null): MediaItem;
@@ -2,6 +2,7 @@ import { AwsClient } from "aws4fetch";
2
2
  import { createError } from "h3";
3
3
  import { useRuntimeConfig } from "#imports";
4
4
  import { formatFileSize, mediaPublicUrl, mediaTypeFor } from "../../shared/index.js";
5
+ import { removeMediaFile, writeMediaFile } from "./media-fs.js";
5
6
  const UPLOAD_TYPE_PREFIXES = ["image/", "video/", "audio/", "font/"];
6
7
  const UPLOAD_TYPE_BLOCKLIST = /* @__PURE__ */ new Set(["image/svg+xml"]);
7
8
  const UPLOAD_TYPES = /* @__PURE__ */ new Set([
@@ -19,10 +20,12 @@ const UPLOAD_TYPES = /* @__PURE__ */ new Set([
19
20
  "application/vnd.ms-powerpoint",
20
21
  "application/vnd.openxmlformats-officedocument.presentationml.presentation"
21
22
  ]);
22
- export function assertUploadContentType(contentType) {
23
+ export function isAllowedUploadType(contentType) {
23
24
  const type = contentType.toLowerCase();
24
- const allowed = !UPLOAD_TYPE_BLOCKLIST.has(type) && (UPLOAD_TYPES.has(type) || UPLOAD_TYPE_PREFIXES.some((prefix) => type.startsWith(prefix)));
25
- if (!allowed) {
25
+ return !UPLOAD_TYPE_BLOCKLIST.has(type) && (UPLOAD_TYPES.has(type) || UPLOAD_TYPE_PREFIXES.some((prefix) => type.startsWith(prefix)));
26
+ }
27
+ export function assertUploadContentType(contentType) {
28
+ if (!isAllowedUploadType(contentType)) {
26
29
  throw createError({
27
30
  statusCode: 415,
28
31
  statusMessage: `Unsupported content type: ${contentType}`
@@ -41,6 +44,15 @@ export function encodeKey(key) {
41
44
  return key.split("/").map(encodeURIComponent).join("/");
42
45
  }
43
46
  export function assertMediaConfigured(media) {
47
+ if (media.storage === "filesystem") {
48
+ if (!media.dir) {
49
+ throw createError({
50
+ statusCode: 501,
51
+ statusMessage: "Media storage is not configured (cms.media.dir in nuxt.config)"
52
+ });
53
+ }
54
+ return;
55
+ }
44
56
  if (media.storage !== "s3") return;
45
57
  if (!media.endpoint || !media.bucket || !media.accessKeyId || !media.secretAccessKey) {
46
58
  throw createError({
@@ -65,9 +77,20 @@ export function useMediaConfig(event) {
65
77
  const publicUrl = (key) => mediaPublicUrl(mediaBaseUrl, key);
66
78
  return { media, publicUrl };
67
79
  }
68
- export function useMediaStorage(event) {
69
- const { media, publicUrl } = useMediaConfig(event);
70
- assertMediaWritable(media);
80
+ export function mediaUploadPath(key) {
81
+ return `/api/cms/admin/media/upload?key=${encodeURIComponent(key)}`;
82
+ }
83
+ function filesystemStore(dir) {
84
+ return {
85
+ uploadTarget: async (key, contentType) => ({
86
+ url: mediaUploadPath(key),
87
+ headers: { "content-type": contentType }
88
+ }),
89
+ write: (key, body) => writeMediaFile(dir, key, body, Math.max(body.byteLength, 1)),
90
+ remove: (key) => removeMediaFile(dir, key)
91
+ };
92
+ }
93
+ function s3Store(media) {
71
94
  const client = new AwsClient({
72
95
  accessKeyId: media.accessKeyId,
73
96
  secretAccessKey: media.secretAccessKey,
@@ -75,7 +98,48 @@ export function useMediaStorage(event) {
75
98
  service: "s3"
76
99
  });
77
100
  const bucketUrl = `${media.endpoint.replace(/\/+$/, "")}/${media.bucket}`;
78
- return { media, client, bucketUrl, publicUrl };
101
+ return {
102
+ async uploadTarget(key, contentType, size) {
103
+ const url = new URL(`${bucketUrl}/${key}`);
104
+ url.searchParams.set("X-Amz-Expires", String(media.presignExpiry));
105
+ const signed = await client.sign(
106
+ new Request(url, {
107
+ method: "PUT",
108
+ headers: { "content-type": contentType, "content-length": String(size) }
109
+ }),
110
+ { aws: { signQuery: true, allHeaders: true } }
111
+ );
112
+ return { url: signed.url, headers: { "content-type": contentType } };
113
+ },
114
+ async write(key, body, contentType) {
115
+ const res = await client.fetch(`${bucketUrl}/${encodeKey(key)}`, {
116
+ method: "PUT",
117
+ body,
118
+ headers: { "content-type": contentType, "content-length": String(body.byteLength) }
119
+ });
120
+ if (!res.ok) {
121
+ throw createError({
122
+ statusCode: 502,
123
+ statusMessage: `Bucket write failed (${res.status})`
124
+ });
125
+ }
126
+ },
127
+ async remove(key) {
128
+ const res = await client.fetch(`${bucketUrl}/${encodeKey(key)}`, { method: "DELETE" });
129
+ if (!res.ok && res.status !== 404) {
130
+ throw createError({
131
+ statusCode: 502,
132
+ statusMessage: `Bucket delete failed (${res.status})`
133
+ });
134
+ }
135
+ }
136
+ };
137
+ }
138
+ export function useMediaStorage(event) {
139
+ const { media, publicUrl } = useMediaConfig(event);
140
+ assertMediaWritable(media);
141
+ const store = media.storage === "filesystem" ? filesystemStore(media.dir) : s3Store(media);
142
+ return { media, store, publicUrl };
79
143
  }
80
144
  export function toMediaItem(row, publicUrl) {
81
145
  return {
@@ -5,4 +5,5 @@ export interface CmsMigration {
5
5
  hash: string;
6
6
  }
7
7
  export declare function runCmsMigrations(db: unknown): Promise<void>;
8
+ export declare function runLibsqlMigrations(db: unknown): Promise<void>;
8
9
  export declare function runD1Migrations(binding: unknown): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  import cmsConfig from "#cms-config";
2
2
  import { migrations as bundledMigrations } from "#cms-migrations";
3
3
  import { useRuntimeConfig } from "#imports";
4
+ import { applyLibsqlMigrations } from "./libsql-migrations.js";
4
5
  const MIGRATIONS_TABLE = "__drizzle_migrations";
5
6
  async function readMigrations(migrationsDir) {
6
7
  if (import.meta.dev) {
@@ -28,6 +29,12 @@ export async function runCmsMigrations(db) {
28
29
  const { dialect, session } = db;
29
30
  await dialect.migrate(migrations, session, {});
30
31
  }
32
+ export async function runLibsqlMigrations(db) {
33
+ const migrations = await pendingMigrations();
34
+ if (!migrations.length) return;
35
+ const { $client } = db;
36
+ await applyLibsqlMigrations($client, migrations);
37
+ }
31
38
  export async function runD1Migrations(binding) {
32
39
  const migrations = await pendingMigrations();
33
40
  if (!migrations.length) return;
@@ -6,7 +6,7 @@ export interface CmsI18n {
6
6
  locales: string[];
7
7
  defaultLocale: string;
8
8
  }
9
- export type MediaStorageMode = 's3' | 'local';
9
+ export type MediaStorageMode = 's3' | 'local' | 'filesystem';
10
10
  export declare const MEDIA_TYPES: readonly ["image", "video", "file"];
11
11
  export type MediaType = (typeof MEDIA_TYPES)[number];
12
12
  export declare function mediaTypeForKey(key: string): MediaType;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
- "version": "0.1.57",
3
+ "version": "0.1.59",
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)",
@@ -121,6 +121,7 @@
121
121
  "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
122
122
  "db:studio": "drizzle-kit studio --config=playground/.nuxt/cms/drizzle.config.ts",
123
123
  "test": "vitest run",
124
+ "test:docker": "vitest run --config test/docker/vitest.config.ts",
124
125
  "test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit",
125
126
  "format": "prettier --write \"**/*.{ts,tsx,js,jsx,md,html,json,sql,vue}\" --log-level error",
126
127
  "release": "sh -c 'changelogen --release --no-github ${1:+--$1} && git push origin main --follow-tags' --"