@xleddyl/nuxt-cms 0.1.58 → 0.1.60

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.58",
4
+ "version": "0.1.60",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -11,7 +11,7 @@ import { minifyIntrospection, outputIntrospectionFile } from 'gql.tada/internal'
11
11
  import { createJiti } from 'jiti';
12
12
  import { typeName, blockTypeName, blocksFieldTypeName, renderGraphqlSdl } from '../dist/runtime/shared/graphql-sdl.js';
13
13
  import { migrationsDirFor } from '../dist/runtime/shared/migrations-dir.js';
14
- import { pageColumnFields, isTranslatableField, isTranslatableMediaField, isMultiSelect, pageAllFields, PAGE_PATH_FIELD, pageRoutes, fieldConditions, pageFieldsTableName, pageMediaTableName, isRequiredField, pageFields, isPrivateField, entryFieldsFor, mediaTypeFilter, DEFAULT_MEDIA_MAX_FILE_SIZE } from '../dist/runtime/shared/index.js';
14
+ import { pageColumnFields, isTranslatableField, isTranslatableMediaField, isMultiSelect, pageAllFields, PAGE_PATH_FIELD, pageRoutes, mobileMediaKey, fieldConditions, pageFieldsTableName, pageMediaTableName, isRequiredField, pageFields, isPrivateField, entryFieldsFor, mediaTypeFilter, DEFAULT_MEDIA_MAX_FILE_SIZE, expandMobileMedia } from '../dist/runtime/shared/index.js';
15
15
  import { scanMediaDirectory, readMediaFileMeta } from '../dist/runtime/server/utils/media-sync.js';
16
16
  import { createHash } from 'node:crypto';
17
17
  import { readFile } from 'node:fs/promises';
@@ -245,6 +245,13 @@ function validateConfig(config, i18n) {
245
245
  if (!locales.length)
246
246
  errors.push(`${fat}: translatable requires cms.i18n.locales in nuxt.config`);
247
247
  }
248
+ if (field.mobile) {
249
+ const mobileKey = mobileMediaKey(key);
250
+ if (field.type !== "media")
251
+ errors.push(`${fat}: mobile is only supported on media fields`);
252
+ else if (allFields[mobileKey] && allFields[mobileKey].mobileOf !== key)
253
+ errors.push(`${fat}: mobile needs the key '${mobileKey}', which is already declared`);
254
+ }
248
255
  if (field.type === "select") {
249
256
  if (!field.options?.length)
250
257
  errors.push(`${fat}: select requires a non-empty options array`);
@@ -337,6 +344,8 @@ function validateConfig(config, i18n) {
337
344
  errors.push(`${bfat}: private fields are not supported inside blocks`);
338
345
  if (blockField.showIf)
339
346
  errors.push(`${bfat}: showIf is not supported inside blocks`);
347
+ if (blockField.mobile)
348
+ errors.push(`${bfat}: mobile is not supported inside blocks`);
340
349
  if (blockField.type === "select" && !blockField.options?.length) {
341
350
  errors.push(`${bfat}: select requires a non-empty options array`);
342
351
  }
@@ -885,6 +894,8 @@ function resolveDatabaseOptions(database) {
885
894
  }
886
895
  return { ...shared, driver: "sqlite", path: database?.path ?? "data/cms.db" };
887
896
  }
897
+ const DEFAULT_FILESYSTEM_MEDIA_DIR = "data/media";
898
+ const DEFAULT_FILESYSTEM_MEDIA_BASE_URL = "/media";
888
899
  function resolveMediaOptions(media) {
889
900
  if (media?.storage === "local") {
890
901
  return {
@@ -896,7 +907,22 @@ function resolveMediaOptions(media) {
896
907
  secretAccessKey: "",
897
908
  presignExpiry: 600,
898
909
  maxFileSize: DEFAULT_MEDIA_MAX_FILE_SIZE,
899
- publicBaseUrl: media.publicBaseUrl
910
+ publicBaseUrl: media.publicBaseUrl,
911
+ dir: ""
912
+ };
913
+ }
914
+ if (media?.storage === "filesystem") {
915
+ return {
916
+ storage: "filesystem",
917
+ endpoint: "",
918
+ region: "auto",
919
+ bucket: "",
920
+ accessKeyId: "",
921
+ secretAccessKey: "",
922
+ presignExpiry: 600,
923
+ maxFileSize: media.maxFileSize ?? DEFAULT_MEDIA_MAX_FILE_SIZE,
924
+ publicBaseUrl: media.publicBaseUrl || DEFAULT_FILESYSTEM_MEDIA_BASE_URL,
925
+ dir: media.dir || DEFAULT_FILESYSTEM_MEDIA_DIR
900
926
  };
901
927
  }
902
928
  return {
@@ -908,7 +934,8 @@ function resolveMediaOptions(media) {
908
934
  secretAccessKey: media?.secretAccessKey ?? "",
909
935
  presignExpiry: media?.presignExpiry ?? 600,
910
936
  maxFileSize: media?.maxFileSize ?? DEFAULT_MEDIA_MAX_FILE_SIZE,
911
- publicBaseUrl: media?.publicBaseUrl ?? ""
937
+ publicBaseUrl: media?.publicBaseUrl ?? "",
938
+ dir: ""
912
939
  };
913
940
  }
914
941
  function resolveModuleOptions(options) {
@@ -948,7 +975,7 @@ async function loadCmsConfig(nuxt, resolver, configPathOption, i18n, logger) {
948
975
  moduleCache: false,
949
976
  alias: { "#nuxt-cms": resolver.resolve("./runtime/shared/index") }
950
977
  });
951
- cmsConfig = await jiti.import(configPath, { default: true });
978
+ cmsConfig = expandMobileMedia(await jiti.import(configPath, { default: true }));
952
979
  } else {
953
980
  logger.warn(
954
981
  `[nuxt-cms] Config file not found: ${configPath}. Using an empty registry \u2014 create a ${configPathOption}.ts with defineCmsConfig().`
@@ -966,27 +993,28 @@ async function loadCmsConfig(nuxt, resolver, configPathOption, i18n, logger) {
966
993
  entry.pages = resolvePageRoutes(entry, discoveredRoutes);
967
994
  routesByEntry[name] = entry.pages;
968
995
  }
969
- if (Object.keys(routesByEntry).length) {
970
- const source = (nuxt.options.alias["#cms-config"] ?? "").replace(/\\/g, "/").replace(/\.[cm]?[jt]s$/, "");
971
- const wrapper = addTemplate({
972
- filename: "cms/config.ts",
973
- write: true,
974
- getContents: () => [
975
- `import config from '${source}'`,
976
- ``,
977
- `const pageRoutes = ${JSON.stringify(routesByEntry, null, 3)}`,
978
- ``,
979
- `for (const [name, routes] of Object.entries(pageRoutes)) {`,
980
- ` const entry = Object(config)[name]`,
981
- ` if (entry) entry.pages = routes`,
982
- `}`,
983
- ``,
984
- `export default config`,
985
- ``
986
- ].join("\n")
987
- });
988
- nuxt.options.alias["#cms-config"] = wrapper.dst;
989
- }
996
+ const source = (nuxt.options.alias["#cms-config"] ?? "").replace(/\\/g, "/").replace(/\.[cm]?[jt]s$/, "");
997
+ const wrapper = addTemplate({
998
+ filename: "cms/config.ts",
999
+ write: true,
1000
+ getContents: () => [
1001
+ `import { expandMobileMedia } from '#nuxt-cms'`,
1002
+ `import config from '${source}'`,
1003
+ ``,
1004
+ `const pageRoutes = ${JSON.stringify(routesByEntry, null, 3)}`,
1005
+ ``,
1006
+ `expandMobileMedia(config)`,
1007
+ ``,
1008
+ `for (const [name, routes] of Object.entries(pageRoutes)) {`,
1009
+ ` const entry = Object(config)[name]`,
1010
+ ` if (entry) entry.pages = routes`,
1011
+ `}`,
1012
+ ``,
1013
+ `export default config`,
1014
+ ``
1015
+ ].join("\n")
1016
+ });
1017
+ nuxt.options.alias["#cms-config"] = wrapper.dst;
990
1018
  const configErrors = validateConfig(cmsConfig, i18n);
991
1019
  if (configErrors.length) {
992
1020
  for (const error of configErrors) logger.error(error);
@@ -1297,6 +1325,18 @@ const module$1 = defineNuxtModule({
1297
1325
  logger.error(`[nuxt-cms] drizzle-kit generate failed (exit code ${code})`);
1298
1326
  });
1299
1327
  }
1328
+ const mediaFilesystemBase = resolved.media.storage === "filesystem" ? `/${resolved.media.publicBaseUrl.split("/").filter(Boolean).join("/")}` : "";
1329
+ if (resolved.media.storage === "filesystem" && !resolved.media.publicBaseUrl.startsWith("/")) {
1330
+ throw new Error(
1331
+ `[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.`
1332
+ );
1333
+ }
1334
+ if (mediaFilesystemBase === "/" || mediaFilesystemBase.startsWith("/api/")) {
1335
+ throw new Error(
1336
+ `[nuxt-cms] media.publicBaseUrl (${resolved.media.publicBaseUrl}) cannot serve the uploaded files: pick a dedicated path such as '/media'.`
1337
+ );
1338
+ }
1339
+ const mediaDir = resolved.media.storage === "filesystem" ? isAbsolute(resolved.media.dir) ? resolved.media.dir : resolve(nuxt.options.rootDir, resolved.media.dir) : "";
1300
1340
  const existingConfig = nuxt.options.runtimeConfig.cms ?? {};
1301
1341
  nuxt.options.runtimeConfig.cms = {
1302
1342
  adminEmail: resolved.admin.email,
@@ -1324,6 +1364,7 @@ const module$1 = defineNuxtModule({
1324
1364
  accessKeyId: resolved.media.accessKeyId,
1325
1365
  secretAccessKey: resolved.media.secretAccessKey,
1326
1366
  localRoot: mediaLocalRoot,
1367
+ dir: mediaDir,
1327
1368
  ...existingConfig.media ?? {}
1328
1369
  }
1329
1370
  };
@@ -1454,6 +1495,17 @@ const module$1 = defineNuxtModule({
1454
1495
  method: "delete",
1455
1496
  handler: resolver.resolve("./runtime/server/api/media-folder.delete")
1456
1497
  });
1498
+ if (mediaFilesystemBase) {
1499
+ addServerHandler({
1500
+ route: "/api/cms/admin/media/upload",
1501
+ method: "put",
1502
+ handler: resolver.resolve("./runtime/server/api/media-upload.put")
1503
+ });
1504
+ addServerHandler({
1505
+ route: `${mediaFilesystemBase}/**`,
1506
+ handler: resolver.resolve("./runtime/server/routes/media-file")
1507
+ });
1508
+ }
1457
1509
  const api = "/api/cms/admin/:collection";
1458
1510
  addServerHandler({
1459
1511
  route: api,
@@ -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,
@@ -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,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 {
@@ -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;
@@ -72,6 +72,8 @@ export interface FieldConfig {
72
72
  blocks?: Record<string, BlockConfig>;
73
73
  mediaType?: MediaType | MediaType[];
74
74
  accept?: string[];
75
+ mobile?: boolean;
76
+ mobileOf?: string;
75
77
  to?: string;
76
78
  cardinality?: 'many-to-one' | 'one-to-one' | 'many-to-many';
77
79
  onDelete?: 'set null' | 'cascade' | 'restrict';
@@ -121,6 +123,9 @@ export interface CmsEntry {
121
123
  tabs?: CmsTab[];
122
124
  table?: CmsTable;
123
125
  }
126
+ export declare const MOBILE_MEDIA_SUFFIX = "Mobile";
127
+ export declare function mobileMediaKey(key: string): string;
128
+ export declare function expandMobileMedia<T extends Record<string, unknown>>(config: T): T;
124
129
  export declare function entryTabs(entry: Pick<CmsEntry, 'tabs'>): CmsTab[];
125
130
  export declare function fieldTab(field: FieldConfig, tabs: CmsTab[]): string | undefined;
126
131
  export declare function fieldsByTab(fields: Record<string, FieldConfig>, tabs: CmsTab[]): Record<string, Record<string, FieldConfig>>;
@@ -191,6 +196,7 @@ export interface MediaFieldInput extends FieldInputBase {
191
196
  mediaType?: MediaType | MediaType[];
192
197
  accept?: string[];
193
198
  translatable?: boolean;
199
+ mobile?: boolean;
194
200
  }
195
201
  export interface RelationFieldInput extends FieldInputBase {
196
202
  type: 'relation';
@@ -199,7 +205,7 @@ export interface RelationFieldInput extends FieldInputBase {
199
205
  onDelete?: 'set null' | 'cascade' | 'restrict';
200
206
  }
201
207
  type BlockField<T> = Omit<T, 'private' | 'showIf' | 'tab'>;
202
- export type BlockFieldInput = BlockField<TextFieldInput> | BlockField<RichtextFieldInput> | BlockField<NumberFieldInput> | BlockField<BooleanFieldInput> | BlockField<DateFieldInput> | BlockField<EmailFieldInput> | BlockField<SelectFieldInput> | BlockField<JsonFieldInput> | BlockField<MediaFieldInput>;
208
+ export type BlockFieldInput = BlockField<TextFieldInput> | BlockField<RichtextFieldInput> | BlockField<NumberFieldInput> | BlockField<BooleanFieldInput> | BlockField<DateFieldInput> | BlockField<EmailFieldInput> | BlockField<SelectFieldInput> | BlockField<JsonFieldInput> | Omit<BlockField<MediaFieldInput>, 'mobile'>;
203
209
  export interface BlockInput {
204
210
  label: string;
205
211
  fields: Record<string, BlockFieldInput>;
@@ -192,6 +192,58 @@ export function localizeBlocks(field, value, locale, defaultLocale) {
192
192
  return value.map((item) => localizeBlock(field, item, locale, defaultLocale));
193
193
  }
194
194
  export const CMS_GRAPHQL_BATCH_LIMIT = 20;
195
+ export const MOBILE_MEDIA_SUFFIX = "Mobile";
196
+ export function mobileMediaKey(key) {
197
+ return `${key}${MOBILE_MEDIA_SUFFIX}`;
198
+ }
199
+ function mobileMediaField(key, field) {
200
+ const { mobile: _mobile, required: _required, ...rest } = field;
201
+ return {
202
+ ...rest,
203
+ label: `${field.label} (mobile)`,
204
+ mobileOf: key
205
+ };
206
+ }
207
+ function withMobileMedia(fields, removed = () => false) {
208
+ const expanded = {};
209
+ for (const [key, field] of Object.entries(fields)) {
210
+ if (field?.mobileOf && Object.hasOwn(fields, field.mobileOf)) continue;
211
+ expanded[key] = field;
212
+ const mobileKey = mobileMediaKey(key);
213
+ if (field?.mobile && field.type === "media" && !Object.hasOwn(fields, mobileKey)) {
214
+ expanded[mobileKey] = mobileMediaField(key, field);
215
+ } else if (field?.mobile && fields[mobileKey]?.mobileOf === key) {
216
+ expanded[mobileKey] = fields[mobileKey];
217
+ } else if (!field && removed(key) && !Object.hasOwn(fields, mobileKey)) {
218
+ expanded[mobileKey] = null;
219
+ }
220
+ }
221
+ return expanded;
222
+ }
223
+ export function expandMobileMedia(config) {
224
+ for (const entry of Object.values(config)) {
225
+ if (!entry?.fields) continue;
226
+ const shared = entry.fields;
227
+ const hasMobile = (key) => !!shared[key]?.mobile && shared[key]?.type === "media";
228
+ entry.fields = withMobileMedia(shared);
229
+ if (entry.overrides) {
230
+ entry.overrides = Object.fromEntries(
231
+ Object.entries(entry.overrides).map(([path, override]) => [
232
+ path,
233
+ withMobileMedia(override, hasMobile)
234
+ ])
235
+ );
236
+ }
237
+ if (entry.columns) {
238
+ entry.columns = [
239
+ ...new Set(
240
+ entry.columns.flatMap((key) => hasMobile(key) ? [key, mobileMediaKey(key)] : [key])
241
+ )
242
+ ];
243
+ }
244
+ }
245
+ return config;
246
+ }
195
247
  export function entryTabs(entry) {
196
248
  return entry.tabs?.length ? entry.tabs : [];
197
249
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
- "version": "0.1.58",
3
+ "version": "0.1.60",
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' --"