@xleddyl/nuxt-cms 0.1.58 → 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 +3 -2
- package/dist/module.d.mts +5 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +44 -2
- package/dist/runtime/server/api/media-folder.delete.js +3 -9
- package/dist/runtime/server/api/media-folder.post.js +3 -10
- package/dist/runtime/server/api/media-presign.post.d.ts +1 -3
- package/dist/runtime/server/api/media-presign.post.js +4 -12
- package/dist/runtime/server/api/media-upload.put.d.ts +2 -0
- package/dist/runtime/server/api/media-upload.put.js +42 -0
- package/dist/runtime/server/api/media.delete.js +3 -6
- package/dist/runtime/server/api/media.post.js +6 -1
- package/dist/runtime/server/routes/media-file.d.ts +2 -0
- package/dist/runtime/server/routes/media-file.js +81 -0
- package/dist/runtime/server/utils/media-fs.d.ts +9 -0
- package/dist/runtime/server/utils/media-fs.js +89 -0
- package/dist/runtime/server/utils/media.d.ts +13 -3
- package/dist/runtime/server/utils/media.js +71 -7
- package/dist/runtime/shared/index.d.ts +1 -1
- package/package.json +2 -1
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
package/dist/module.json
CHANGED
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,
|
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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
|
|
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:
|
|
35
|
+
uploadUrl: target.url,
|
|
44
36
|
method: "PUT",
|
|
45
|
-
headers:
|
|
37
|
+
headers: target.headers,
|
|
46
38
|
publicUrl: publicUrl(key),
|
|
47
39
|
expiresIn: media.presignExpiry
|
|
48
40
|
};
|
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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,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
|
-
|
|
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
|
|
23
|
+
export function isAllowedUploadType(contentType) {
|
|
23
24
|
const type = contentType.toLowerCase();
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
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 {
|
|
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xleddyl/nuxt-cms",
|
|
3
|
-
"version": "0.1.
|
|
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' --"
|