@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 +3 -2
- package/dist/module.d.mts +5 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +77 -25
- 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 +8 -2
- package/dist/runtime/shared/index.js +52 -0
- 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
|
@@ -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
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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 {
|
|
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;
|
|
@@ -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.
|
|
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' --"
|