mktcms 0.1.27 → 0.1.29
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/dist/module.json +1 -1
- package/dist/module.mjs +5 -0
- package/dist/runtime/app/components/content/index.vue +26 -1
- package/dist/runtime/app/composables/useImport.d.ts +6 -0
- package/dist/runtime/app/composables/useImport.js +37 -0
- package/dist/runtime/server/api/admin/blob.d.ts +1 -1
- package/dist/runtime/server/api/admin/blob.js +5 -2
- package/dist/runtime/server/api/admin/import.d.ts +5 -0
- package/dist/runtime/server/api/admin/import.js +91 -0
- package/dist/runtime/server/api/content/[path].js +23 -3
- package/dist/runtime/server/utils/toNodeBuffer.d.ts +1 -0
- package/dist/runtime/server/utils/toNodeBuffer.js +19 -0
- package/package.json +3 -1
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -84,6 +84,11 @@ const module$1 = defineNuxtModule({
|
|
|
84
84
|
route: "/api/admin/blob",
|
|
85
85
|
handler: resolver.resolve("./runtime/server/api/admin/blob")
|
|
86
86
|
});
|
|
87
|
+
addServerHandler({
|
|
88
|
+
route: "/api/admin/import",
|
|
89
|
+
method: "post",
|
|
90
|
+
handler: resolver.resolve("./runtime/server/api/admin/import")
|
|
91
|
+
});
|
|
87
92
|
addServerHandler({
|
|
88
93
|
route: "/api/admin/upload",
|
|
89
94
|
handler: resolver.resolve("./runtime/server/api/admin/upload")
|
|
@@ -3,10 +3,12 @@ import { useFetch } from "#app";
|
|
|
3
3
|
import Files from "./files.vue";
|
|
4
4
|
import Dirs from "./dirs.vue";
|
|
5
5
|
import usePathParam from "../../composables/usePathParam";
|
|
6
|
+
import useImport from "../../composables/useImport";
|
|
6
7
|
const { path } = usePathParam();
|
|
7
|
-
const { data: list } = await useFetch("/api/admin/list", {
|
|
8
|
+
const { data: list, refresh } = await useFetch("/api/admin/list", {
|
|
8
9
|
query: { path }
|
|
9
10
|
});
|
|
11
|
+
const { fileInput, uploadFile } = useImport();
|
|
10
12
|
</script>
|
|
11
13
|
|
|
12
14
|
<template>
|
|
@@ -21,5 +23,28 @@ const { data: list } = await useFetch("/api/admin/list", {
|
|
|
21
23
|
:dirs="list.dirs"
|
|
22
24
|
style="margin-top: 8px;"
|
|
23
25
|
/>
|
|
26
|
+
|
|
27
|
+
<div
|
|
28
|
+
v-if="list?.files.length === 0 && list.dirs.length === 0"
|
|
29
|
+
class="flex flex-col gap-4"
|
|
30
|
+
>
|
|
31
|
+
<p>Keine Dateien oder Verzeichnisse gefunden.</p>
|
|
32
|
+
<button
|
|
33
|
+
class="button"
|
|
34
|
+
@click="fileInput?.click()"
|
|
35
|
+
>
|
|
36
|
+
Dateien importieren
|
|
37
|
+
</button>
|
|
38
|
+
<input
|
|
39
|
+
ref="fileInput"
|
|
40
|
+
type="file"
|
|
41
|
+
class="hidden"
|
|
42
|
+
accept=".zip"
|
|
43
|
+
@change="async (e) => {
|
|
44
|
+
await uploadFile(e);
|
|
45
|
+
await refresh();
|
|
46
|
+
}"
|
|
47
|
+
>
|
|
48
|
+
</div>
|
|
24
49
|
</div>
|
|
25
50
|
</template>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export default function useImport(): {
|
|
2
|
+
uploadError: import("vue").Ref<string | null, string | null>;
|
|
3
|
+
isUploading: import("vue").Ref<boolean, boolean>;
|
|
4
|
+
fileInput: import("vue").Ref<HTMLInputElement | null, HTMLInputElement | null>;
|
|
5
|
+
uploadFile: (event: Event) => Promise<void>;
|
|
6
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ref } from "vue";
|
|
2
|
+
export default function useImport() {
|
|
3
|
+
const uploadError = ref(null);
|
|
4
|
+
const fileInput = ref(null);
|
|
5
|
+
const isUploading = ref(false);
|
|
6
|
+
async function uploadFile(event) {
|
|
7
|
+
if (isUploading.value) return;
|
|
8
|
+
isUploading.value = true;
|
|
9
|
+
uploadError.value = null;
|
|
10
|
+
const input = event.target;
|
|
11
|
+
if (!input.files) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const file = input.files[0];
|
|
15
|
+
if (!file) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const formData = new FormData();
|
|
19
|
+
formData.append("file", file);
|
|
20
|
+
try {
|
|
21
|
+
await $fetch("/api/admin/import", {
|
|
22
|
+
method: "POST",
|
|
23
|
+
body: formData
|
|
24
|
+
});
|
|
25
|
+
} catch (error) {
|
|
26
|
+
uploadError.value = error.data?.statusMessage || "Fehler beim Hochladen der Datei";
|
|
27
|
+
input.value = "";
|
|
28
|
+
}
|
|
29
|
+
isUploading.value = false;
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
uploadError,
|
|
33
|
+
isUploading,
|
|
34
|
+
fileInput,
|
|
35
|
+
uploadFile
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<
|
|
1
|
+
declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<void>>;
|
|
2
2
|
export default _default;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { createError, defineEventHandler, getValidatedQuery } from "h3";
|
|
2
|
+
import { createError, defineEventHandler, getValidatedQuery, send } from "h3";
|
|
3
3
|
import { useRuntimeConfig, useStorage } from "nitropack/runtime";
|
|
4
|
+
import { toNodeBuffer } from "../../utils/toNodeBuffer.js";
|
|
4
5
|
const querySchema = z.object({
|
|
5
6
|
path: z.string().min(1)
|
|
6
7
|
});
|
|
@@ -33,5 +34,7 @@ export default defineEventHandler(async (event) => {
|
|
|
33
34
|
};
|
|
34
35
|
const contentType = mimeTypes[extension] || "application/octet-stream";
|
|
35
36
|
event.node.res.setHeader("Content-Type", contentType);
|
|
36
|
-
|
|
37
|
+
const body = toNodeBuffer(file);
|
|
38
|
+
event.node.res.setHeader("Content-Length", String(body.byteLength));
|
|
39
|
+
return send(event, body);
|
|
37
40
|
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createError, defineEventHandler, readMultipartFormData } from "h3";
|
|
2
|
+
import { useRuntimeConfig, useStorage } from "nitropack/runtime";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import unzipper from "unzipper";
|
|
5
|
+
function sanitizePathSegment(segment) {
|
|
6
|
+
return segment.replace(/[/:\\]/g, "_").replace(/\0/g, "");
|
|
7
|
+
}
|
|
8
|
+
function zipPathToColonKey(entryPath) {
|
|
9
|
+
const original = entryPath.replace(/\\/g, "/");
|
|
10
|
+
if (original.startsWith("/")) {
|
|
11
|
+
return "";
|
|
12
|
+
}
|
|
13
|
+
let p = original.replace(/^\.\//, "");
|
|
14
|
+
if (/^[a-z]:\//i.test(p)) {
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
if (!p || p.includes("\0")) {
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
p = path.posix.normalize(p);
|
|
21
|
+
if (p === "." || p === ".." || p.startsWith("../") || p.includes("/../")) {
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
const parts = p.split("/").filter(Boolean);
|
|
25
|
+
if (parts.some((part) => part === "." || part === "..")) {
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
const sanitizedParts = parts.map(sanitizePathSegment);
|
|
29
|
+
return sanitizedParts.join(":");
|
|
30
|
+
}
|
|
31
|
+
function withPrefix(s3Prefix, colonKey) {
|
|
32
|
+
return [s3Prefix, colonKey].filter(Boolean).join(":");
|
|
33
|
+
}
|
|
34
|
+
export default defineEventHandler(async (event) => {
|
|
35
|
+
const form = await readMultipartFormData(event);
|
|
36
|
+
const { mktcms: { s3Prefix } } = useRuntimeConfig();
|
|
37
|
+
if (!form) {
|
|
38
|
+
throw createError({
|
|
39
|
+
statusCode: 400,
|
|
40
|
+
statusMessage: "No form data received"
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const file = form.find((item) => item.name === "file");
|
|
44
|
+
if (!file) {
|
|
45
|
+
throw createError({
|
|
46
|
+
statusCode: 400,
|
|
47
|
+
statusMessage: "Missing file"
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (!file.filename || !file.data) {
|
|
51
|
+
throw createError({
|
|
52
|
+
statusCode: 400,
|
|
53
|
+
statusMessage: "Invalid file upload"
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const allowedExtensions = [".zip"];
|
|
57
|
+
const fileExtension = file.filename.toLowerCase().slice(file.filename.lastIndexOf("."));
|
|
58
|
+
if (!allowedExtensions.includes(fileExtension)) {
|
|
59
|
+
throw createError({
|
|
60
|
+
statusCode: 400,
|
|
61
|
+
statusMessage: "Invalid file type. Only ZIP files are allowed for import."
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const zipBuffer = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.data);
|
|
65
|
+
let zip;
|
|
66
|
+
try {
|
|
67
|
+
zip = await unzipper.Open.buffer(zipBuffer);
|
|
68
|
+
} catch {
|
|
69
|
+
throw createError({ statusCode: 400, statusMessage: "Invalid ZIP file" });
|
|
70
|
+
}
|
|
71
|
+
const storage = useStorage("content");
|
|
72
|
+
const written = /* @__PURE__ */ new Set();
|
|
73
|
+
for (const entry of zip.files) {
|
|
74
|
+
if (!entry.path) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (entry.type === "Directory") continue;
|
|
78
|
+
const fileColonKey = zipPathToColonKey(entry.path);
|
|
79
|
+
if (!fileColonKey) {
|
|
80
|
+
throw createError({
|
|
81
|
+
statusCode: 400,
|
|
82
|
+
statusMessage: `Invalid ZIP entry path: ${entry.path}`
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const fullKey = withPrefix(s3Prefix, fileColonKey);
|
|
86
|
+
const data = await entry.buffer();
|
|
87
|
+
await storage.setItemRaw(fullKey, data);
|
|
88
|
+
written.add(fullKey);
|
|
89
|
+
}
|
|
90
|
+
return { success: true, count: written.size };
|
|
91
|
+
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { createError, defineEventHandler, getValidatedRouterParams } from "h3";
|
|
2
|
+
import { createError, defineEventHandler, getValidatedRouterParams, send } from "h3";
|
|
3
3
|
import { useRuntimeConfig, useStorage } from "nitropack/runtime";
|
|
4
4
|
import { parse } from "csv-parse/sync";
|
|
5
5
|
import { marked } from "marked";
|
|
6
6
|
import { parseFrontmatter } from "../../utils/parseFrontmatter.js";
|
|
7
|
+
import { toNodeBuffer } from "../../utils/toNodeBuffer.js";
|
|
7
8
|
function parsedFile(fullPath, file) {
|
|
8
9
|
if (fullPath.endsWith(".json") && typeof file === "string") {
|
|
9
10
|
try {
|
|
@@ -61,7 +62,14 @@ export default defineEventHandler(async (event) => {
|
|
|
61
62
|
const isCSV = decodedPath.endsWith(".csv");
|
|
62
63
|
const isMarkdown = decodedPath.endsWith(".md");
|
|
63
64
|
if (isImage) {
|
|
64
|
-
|
|
65
|
+
const ext = decodedPath.split(".").pop()?.toLowerCase();
|
|
66
|
+
if (ext === "svg") {
|
|
67
|
+
event.node.res.setHeader("Content-Type", "image/svg+xml");
|
|
68
|
+
} else if (ext === "jpg") {
|
|
69
|
+
event.node.res.setHeader("Content-Type", "image/jpeg");
|
|
70
|
+
} else {
|
|
71
|
+
event.node.res.setHeader("Content-Type", "image/" + ext);
|
|
72
|
+
}
|
|
65
73
|
} else if (isPdf) {
|
|
66
74
|
event.node.res.setHeader("Content-Type", "application/pdf");
|
|
67
75
|
} else if (isJson || isCSV || isMarkdown) {
|
|
@@ -70,7 +78,19 @@ export default defineEventHandler(async (event) => {
|
|
|
70
78
|
event.node.res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
71
79
|
}
|
|
72
80
|
const storage = useStorage("content");
|
|
73
|
-
|
|
81
|
+
if (isImage || isPdf) {
|
|
82
|
+
const raw = await storage.getItemRaw(fullPath);
|
|
83
|
+
if (!raw) {
|
|
84
|
+
throw createError({
|
|
85
|
+
statusCode: 404,
|
|
86
|
+
statusMessage: "File not found"
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const body = toNodeBuffer(raw);
|
|
90
|
+
event.node.res.setHeader("Content-Length", String(body.byteLength));
|
|
91
|
+
return send(event, body);
|
|
92
|
+
}
|
|
93
|
+
const file = await storage.getItem(fullPath);
|
|
74
94
|
if (!file) {
|
|
75
95
|
throw createError({
|
|
76
96
|
statusCode: 404,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function toNodeBuffer(raw: unknown): Buffer;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createError } from "h3";
|
|
2
|
+
export function toNodeBuffer(raw) {
|
|
3
|
+
if (Buffer.isBuffer(raw)) {
|
|
4
|
+
return raw;
|
|
5
|
+
}
|
|
6
|
+
if (typeof raw === "string") {
|
|
7
|
+
return Buffer.from(raw);
|
|
8
|
+
}
|
|
9
|
+
if (raw instanceof ArrayBuffer) {
|
|
10
|
+
return Buffer.from(raw);
|
|
11
|
+
}
|
|
12
|
+
if (ArrayBuffer.isView(raw)) {
|
|
13
|
+
return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength);
|
|
14
|
+
}
|
|
15
|
+
throw createError({
|
|
16
|
+
statusCode: 500,
|
|
17
|
+
statusMessage: "Invalid binary file"
|
|
18
|
+
});
|
|
19
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mktcms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.29",
|
|
4
4
|
"description": "Simple CMS module for Nuxt",
|
|
5
5
|
"repository": "mktcode/mktcms",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"defu": "^6.1.4",
|
|
46
46
|
"marked": "^17.0.1",
|
|
47
47
|
"nodemailer": "^7.0.12",
|
|
48
|
+
"unzipper": "^0.12.3",
|
|
48
49
|
"yaml": "^2.8.2",
|
|
49
50
|
"zod": "^4.3.5"
|
|
50
51
|
},
|
|
@@ -58,6 +59,7 @@
|
|
|
58
59
|
"@tailwindcss/typography": "^0.5.19",
|
|
59
60
|
"@types/node": "latest",
|
|
60
61
|
"@types/nodemailer": "^7.0.5",
|
|
62
|
+
"@types/unzipper": "^0.10.11",
|
|
61
63
|
"changelogen": "^0.6.2",
|
|
62
64
|
"eslint": "^9.39.2",
|
|
63
65
|
"nuxt": "^4.2.2",
|