mktcms 0.2.12 → 0.2.14
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 +5 -3
- package/dist/module.json +1 -1
- package/dist/module.mjs +8 -5
- package/dist/runtime/app/components/content/editor/txt.vue +21 -1
- package/dist/runtime/app/components/content/index.vue +5 -17
- package/dist/runtime/app/components/content/upload.vue +7 -3
- package/dist/runtime/app/composables/useFileType.js +6 -6
- package/dist/runtime/app/pages/admin/login.vue +34 -7
- package/dist/runtime/server/api/admin/blob.js +4 -3
- package/dist/runtime/server/api/admin/csv.js +3 -2
- package/dist/runtime/server/api/admin/csv.post.js +6 -4
- package/dist/runtime/server/api/admin/delete.js +6 -4
- package/dist/runtime/server/api/admin/download.js +11 -8
- package/dist/runtime/server/api/admin/git-branch.js +3 -2
- package/dist/runtime/server/api/admin/git-history.js +3 -2
- package/dist/runtime/server/api/admin/git-update-status.js +3 -2
- package/dist/runtime/server/api/admin/git-update.post.js +2 -2
- package/dist/runtime/server/api/admin/image.post.js +14 -10
- package/dist/runtime/server/api/admin/list.js +11 -6
- package/dist/runtime/server/api/admin/login.js +6 -4
- package/dist/runtime/server/api/admin/logout.js +2 -1
- package/dist/runtime/server/api/admin/md.js +3 -2
- package/dist/runtime/server/api/admin/md.post.js +6 -4
- package/dist/runtime/server/api/admin/pdf.post.js +13 -9
- package/dist/runtime/server/api/admin/txt.js +3 -2
- package/dist/runtime/server/api/admin/txt.post.js +13 -4
- package/dist/runtime/server/api/admin/upload.js +10 -6
- package/dist/runtime/server/api/content/[path].js +14 -12
- package/dist/runtime/server/api/content/list.js +9 -7
- package/dist/runtime/server/middleware/auth.js +2 -1
- package/dist/runtime/server/utils/authCookie.d.ts +9 -0
- package/dist/runtime/server/utils/authCookie.js +12 -0
- package/dist/runtime/server/utils/contentKey.d.ts +2 -0
- package/dist/runtime/server/utils/contentKey.js +67 -0
- package/dist/runtime/server/utils/gitErrorSanitization.d.ts +1 -0
- package/dist/runtime/server/utils/gitErrorSanitization.js +17 -0
- package/dist/runtime/server/utils/gitVersioning.d.ts +1 -2
- package/dist/runtime/server/utils/gitVersioning.js +2 -13
- package/dist/runtime/server/utils/loginRateLimit.d.ts +4 -0
- package/dist/runtime/server/utils/loginRateLimit.js +72 -0
- package/dist/runtime/server/utils/uploadGuard.d.ts +2 -0
- package/dist/runtime/server/utils/uploadGuard.js +20 -0
- package/dist/runtime/shared/contentFiles.d.ts +15 -0
- package/dist/runtime/shared/contentFiles.js +38 -0
- package/package.json +1 -1
- package/dist/runtime/app/composables/useImport.d.ts +0 -6
- package/dist/runtime/app/composables/useImport.js +0 -37
- package/dist/runtime/server/api/admin/import.d.ts +0 -5
- package/dist/runtime/server/api/admin/import.js +0 -86
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
#
|
|
1
|
+
# MktCMS
|
|
2
|
+
|
|
3
|
+
A file based CMS for self-hosted Nuxt apps.
|
|
2
4
|
|
|
3
5
|

|
|
4
6
|
|
|
5
|
-
This is my personal, minimalist alternative to @nuxt/content and Studio, which are fantastic projects, but sometimes lacking the kind of complete documentation I’m looking for. Over time I will
|
|
7
|
+
This is my personal, minimalist alternative to @nuxt/content and Studio, which are fantastic projects, but sometimes lacking the kind of complete documentation I’m looking for. Over time I will maybe migrate more and more (MDC already supported), and just keep [the website template](https://github.com/mktcode/mktcms-website-template) and the [server utility](https://github.com/mktcode/mktcms-server). If you find this useful, feel free to use it and contribute to it.
|
|
6
8
|
|
|
7
9
|
[![npm version][npm-version-src]][npm-version-href]
|
|
8
10
|
[![npm downloads][npm-downloads-src]][npm-downloads-href]
|
|
@@ -15,7 +17,7 @@ This is my personal, minimalist alternative to @nuxt/content and Studio, which a
|
|
|
15
17
|
|
|
16
18
|
## Features
|
|
17
19
|
|
|
18
|
-
- Simple Admin UI to manage content
|
|
20
|
+
- Simple Admin UI with Git integration to manage content
|
|
19
21
|
- Composables to use the content in your Nuxt app
|
|
20
22
|
- `sendMail` utility to send emails via SMTP
|
|
21
23
|
- [MDC](https://github.com/nuxt-content/mdc) support
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -15,6 +15,14 @@ const module$1 = defineNuxtModule({
|
|
|
15
15
|
const resolver = createResolver(import.meta.url);
|
|
16
16
|
_nuxt.options.runtimeConfig.mktcms = defu((_nuxt.options.runtimeConfig.mktcms, {
|
|
17
17
|
adminAuthKey: "",
|
|
18
|
+
authCookieMaxAgeSeconds: 7 * 24 * 60 * 60,
|
|
19
|
+
authCookiePath: "/",
|
|
20
|
+
authCookieSameSite: "lax",
|
|
21
|
+
authCookieSecure: process.env.NODE_ENV === "production",
|
|
22
|
+
loginRateLimitMaxAttempts: 5,
|
|
23
|
+
loginRateLimitWindowSeconds: 300,
|
|
24
|
+
loginRateLimitBlockSeconds: 600,
|
|
25
|
+
uploadMaxBytes: 50 * 1024 * 1024,
|
|
18
26
|
smtpHost: "",
|
|
19
27
|
smtpPort: 465,
|
|
20
28
|
smtpSecure: true,
|
|
@@ -143,11 +151,6 @@ const module$1 = defineNuxtModule({
|
|
|
143
151
|
route: "/api/admin/blob",
|
|
144
152
|
handler: resolver.resolve("./runtime/server/api/admin/blob")
|
|
145
153
|
});
|
|
146
|
-
addServerHandler({
|
|
147
|
-
route: "/api/admin/import",
|
|
148
|
-
method: "post",
|
|
149
|
-
handler: resolver.resolve("./runtime/server/api/admin/import")
|
|
150
|
-
});
|
|
151
154
|
addServerHandler({
|
|
152
155
|
route: "/api/admin/upload",
|
|
153
156
|
handler: resolver.resolve("./runtime/server/api/admin/upload")
|
|
@@ -6,6 +6,7 @@ const { path } = usePathParam();
|
|
|
6
6
|
const { data: content } = await useFetch(`/api/admin/txt?path=${path}`);
|
|
7
7
|
const isSaving = ref(false);
|
|
8
8
|
const savingSuccessful = ref(false);
|
|
9
|
+
const commitMessage = ref("Inhaltliche \xC4nderungen");
|
|
9
10
|
async function saveContent() {
|
|
10
11
|
if (content.value === void 0) return;
|
|
11
12
|
isSaving.value = true;
|
|
@@ -13,7 +14,8 @@ async function saveContent() {
|
|
|
13
14
|
await useFetch(`/api/admin/txt?path=${path}`, {
|
|
14
15
|
method: "POST",
|
|
15
16
|
body: {
|
|
16
|
-
text: content.value
|
|
17
|
+
text: content.value,
|
|
18
|
+
commitMessage: commitMessage.value
|
|
17
19
|
}
|
|
18
20
|
});
|
|
19
21
|
isSaving.value = false;
|
|
@@ -28,9 +30,27 @@ async function saveContent() {
|
|
|
28
30
|
class="w-full h-24 resize-y border border-gray-300 p-2 box-border font-mono"
|
|
29
31
|
/>
|
|
30
32
|
|
|
33
|
+
<div class="mt-3">
|
|
34
|
+
<label
|
|
35
|
+
for="commit-message"
|
|
36
|
+
class="block mb-1"
|
|
37
|
+
>
|
|
38
|
+
Kommentar / Änderungsgrund
|
|
39
|
+
</label>
|
|
40
|
+
<input
|
|
41
|
+
id="commit-message"
|
|
42
|
+
v-model="commitMessage"
|
|
43
|
+
type="text"
|
|
44
|
+
required
|
|
45
|
+
class="w-full border border-gray-200 rounded-sm px-3 py-2"
|
|
46
|
+
placeholder="Inhaltliche Änderungen"
|
|
47
|
+
>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
31
50
|
<button
|
|
32
51
|
type="button"
|
|
33
52
|
class="button w-full justify-center mt-3"
|
|
53
|
+
:disabled="!commitMessage.trim() || isSaving"
|
|
34
54
|
@click="saveContent"
|
|
35
55
|
>
|
|
36
56
|
<span v-if="isSaving">Speichern...</span>
|
|
@@ -3,11 +3,9 @@ import { useFetch } from "#app";
|
|
|
3
3
|
import TreeNode from "./treeNode.vue";
|
|
4
4
|
import FileIcon from "./fileIcon.vue";
|
|
5
5
|
import FileButtons from "./fileButtons.vue";
|
|
6
|
-
|
|
7
|
-
const { data: list, refresh } = await useFetch("/api/admin/list", {
|
|
6
|
+
const { data: list } = await useFetch("/api/admin/list", {
|
|
8
7
|
query: { path: "" }
|
|
9
8
|
});
|
|
10
|
-
const { fileInput, uploadFile } = useImport();
|
|
11
9
|
function filenameWithoutExtension(filename) {
|
|
12
10
|
return filename.replace(/\.[^/.]+$/, "");
|
|
13
11
|
}
|
|
@@ -86,22 +84,12 @@ function fileExtension(filename) {
|
|
|
86
84
|
class="flex flex-col gap-4"
|
|
87
85
|
>
|
|
88
86
|
<p>Keine Dateien oder Verzeichnisse gefunden.</p>
|
|
89
|
-
<
|
|
87
|
+
<NuxtLink
|
|
88
|
+
to="/admin/new"
|
|
90
89
|
class="button"
|
|
91
|
-
@click="fileInput?.click()"
|
|
92
|
-
>
|
|
93
|
-
Dateien importieren
|
|
94
|
-
</button>
|
|
95
|
-
<input
|
|
96
|
-
ref="fileInput"
|
|
97
|
-
type="file"
|
|
98
|
-
class="hidden"
|
|
99
|
-
accept=".zip"
|
|
100
|
-
@change="async (e) => {
|
|
101
|
-
await uploadFile(e);
|
|
102
|
-
await refresh();
|
|
103
|
-
}"
|
|
104
90
|
>
|
|
91
|
+
Datei hochladen
|
|
92
|
+
</NuxtLink>
|
|
105
93
|
</div>
|
|
106
94
|
</div>
|
|
107
95
|
</template>
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { onMounted, ref, useFetch, useRoute } from "#imports";
|
|
3
3
|
import useAdminUpload from "../../composables/useAdminUpload";
|
|
4
|
+
import { CONTENT_UPLOAD_EXTENSIONS, IMAGE_EXTENSIONS, PDF_EXTENSIONS, toAcceptAttribute } from "../../../shared/contentFiles";
|
|
4
5
|
const { data: list } = await useFetch("/api/admin/list");
|
|
5
6
|
const route = useRoute();
|
|
6
7
|
const dir = ref(route.query.dir || "");
|
|
7
8
|
const newSubdir = ref("");
|
|
9
|
+
const uploadAccept = toAcceptAttribute(CONTENT_UPLOAD_EXTENSIONS);
|
|
10
|
+
const imageAccept = toAcceptAttribute(IMAGE_EXTENSIONS);
|
|
11
|
+
const pdfAccept = toAcceptAttribute(PDF_EXTENSIONS);
|
|
8
12
|
const { isUploading, fileInput, fileInputImg, fileInputPdf, path, uploadFiles } = useAdminUpload();
|
|
9
13
|
onMounted(() => {
|
|
10
14
|
path.value = dir.value;
|
|
@@ -98,7 +102,7 @@ onMounted(() => {
|
|
|
98
102
|
ref="fileInput"
|
|
99
103
|
class="hidden"
|
|
100
104
|
type="file"
|
|
101
|
-
accept="
|
|
105
|
+
:accept="uploadAccept"
|
|
102
106
|
@change="async (e) => {
|
|
103
107
|
await uploadFiles(e);
|
|
104
108
|
}"
|
|
@@ -107,7 +111,7 @@ onMounted(() => {
|
|
|
107
111
|
ref="fileInputImg"
|
|
108
112
|
class="hidden"
|
|
109
113
|
type="file"
|
|
110
|
-
accept="
|
|
114
|
+
:accept="imageAccept"
|
|
111
115
|
@change="async (e) => {
|
|
112
116
|
await uploadFiles(e);
|
|
113
117
|
}"
|
|
@@ -116,7 +120,7 @@ onMounted(() => {
|
|
|
116
120
|
ref="fileInputPdf"
|
|
117
121
|
class="hidden"
|
|
118
122
|
type="file"
|
|
119
|
-
accept="
|
|
123
|
+
:accept="pdfAccept"
|
|
120
124
|
@change="async (e) => {
|
|
121
125
|
await uploadFiles(e);
|
|
122
126
|
}"
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
import { isCsvPath, isImagePath, isMarkdownPath, isPdfPath, isTextPath } from "../../shared/contentFiles.js";
|
|
1
2
|
export default function useFileType(path) {
|
|
2
|
-
const
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const isText = normalizedPath.match(/\.(txt|json)$/) !== null;
|
|
3
|
+
const isImage = isImagePath(path);
|
|
4
|
+
const isPdf = isPdfPath(path);
|
|
5
|
+
const isMarkdown = isMarkdownPath(path);
|
|
6
|
+
const isCsv = isCsvPath(path);
|
|
7
|
+
const isText = isTextPath(path);
|
|
8
8
|
return {
|
|
9
9
|
isImage,
|
|
10
10
|
isPdf,
|
|
@@ -7,6 +7,7 @@ const { public: { mktcms: { siteUrl } } } = useRuntimeConfig();
|
|
|
7
7
|
const adminAuthKey = ref("");
|
|
8
8
|
const adminAuthKeyFileInput = ref(null);
|
|
9
9
|
const wasHere = useLocalStorage("mktcms_admin_was_here", false);
|
|
10
|
+
const loginError = ref(null);
|
|
10
11
|
function openAdminAuthKeyFilePicker() {
|
|
11
12
|
adminAuthKeyFileInput.value?.click();
|
|
12
13
|
}
|
|
@@ -28,14 +29,31 @@ async function onAdminAuthKeyFileSelected(event) {
|
|
|
28
29
|
input.value = "";
|
|
29
30
|
}
|
|
30
31
|
async function login() {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
loginError.value = null;
|
|
33
|
+
try {
|
|
34
|
+
await $fetch("/api/admin/login", {
|
|
35
|
+
method: "POST",
|
|
36
|
+
body: {
|
|
37
|
+
adminAuthKey: adminAuthKey.value
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
wasHere.value = true;
|
|
41
|
+
await navigateTo("/admin");
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const statusCode = Number(error?.statusCode || error?.response?.status || error?.data?.statusCode);
|
|
44
|
+
if (statusCode === 429) {
|
|
45
|
+
const retryAfterSeconds = Number(
|
|
46
|
+
error?.data?.data?.retryAfterSeconds || error?.response?._data?.data?.retryAfterSeconds
|
|
47
|
+
);
|
|
48
|
+
if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
|
|
49
|
+
loginError.value = `Zu viele Anmeldeversuche. Bitte warte ${retryAfterSeconds} Sekunden und versuche es dann erneut.`;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
loginError.value = "Zu viele Anmeldeversuche. Bitte warte einen Moment und versuche es dann erneut.";
|
|
53
|
+
return;
|
|
35
54
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
await navigateTo("/admin");
|
|
55
|
+
loginError.value = "Anmeldung fehlgeschlagen. Der eingegebene Schl\xFCssel ist ung\xFCltig.";
|
|
56
|
+
}
|
|
39
57
|
}
|
|
40
58
|
</script>
|
|
41
59
|
|
|
@@ -86,6 +104,15 @@ async function login() {
|
|
|
86
104
|
>
|
|
87
105
|
Anmelden
|
|
88
106
|
</button>
|
|
107
|
+
|
|
108
|
+
<div
|
|
109
|
+
v-if="loginError"
|
|
110
|
+
class="mt-3 rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700"
|
|
111
|
+
role="alert"
|
|
112
|
+
aria-live="polite"
|
|
113
|
+
>
|
|
114
|
+
{{ loginError }}
|
|
115
|
+
</div>
|
|
89
116
|
</div>
|
|
90
117
|
</div>
|
|
91
118
|
</Admin>
|
|
@@ -2,21 +2,22 @@ import { z } from "zod";
|
|
|
2
2
|
import { createError, defineEventHandler, getValidatedQuery, send } from "h3";
|
|
3
3
|
import { useStorage } from "nitropack/runtime";
|
|
4
4
|
import { toNodeBuffer } from "../../utils/toNodeBuffer.js";
|
|
5
|
+
import { normalizeContentKey } from "../../utils/contentKey.js";
|
|
5
6
|
const querySchema = z.object({
|
|
6
7
|
path: z.string().min(1)
|
|
7
8
|
});
|
|
8
9
|
export default defineEventHandler(async (event) => {
|
|
9
10
|
const { path } = await getValidatedQuery(event, (query) => querySchema.parse(query));
|
|
10
|
-
const
|
|
11
|
+
const contentKey = normalizeContentKey(path);
|
|
11
12
|
const storage = useStorage("content");
|
|
12
|
-
const file = await storage.getItemRaw(
|
|
13
|
+
const file = await storage.getItemRaw(contentKey);
|
|
13
14
|
if (!file) {
|
|
14
15
|
throw createError({
|
|
15
16
|
statusCode: 404,
|
|
16
17
|
statusMessage: "File not found"
|
|
17
18
|
});
|
|
18
19
|
}
|
|
19
|
-
const extension =
|
|
20
|
+
const extension = contentKey.split(".").pop()?.toLowerCase() || "bin";
|
|
20
21
|
const mimeTypes = {
|
|
21
22
|
png: "image/png",
|
|
22
23
|
jpg: "image/jpeg",
|
|
@@ -2,14 +2,15 @@ import { z } from "zod";
|
|
|
2
2
|
import { createError, defineEventHandler, getValidatedQuery } from "h3";
|
|
3
3
|
import { useStorage } from "nitropack/runtime";
|
|
4
4
|
import { parse } from "csv-parse/sync";
|
|
5
|
+
import { normalizeContentKey } from "../../utils/contentKey.js";
|
|
5
6
|
const querySchema = z.object({
|
|
6
7
|
path: z.string().min(1)
|
|
7
8
|
});
|
|
8
9
|
export default defineEventHandler(async (event) => {
|
|
9
10
|
const { path } = await getValidatedQuery(event, (query) => querySchema.parse(query));
|
|
10
|
-
const
|
|
11
|
+
const contentKey = normalizeContentKey(path);
|
|
11
12
|
const storage = useStorage("content");
|
|
12
|
-
const file = await storage.getItem(
|
|
13
|
+
const file = await storage.getItem(contentKey);
|
|
13
14
|
if (!file) {
|
|
14
15
|
throw createError({
|
|
15
16
|
statusCode: 404,
|
|
@@ -3,6 +3,8 @@ import { defineEventHandler, getValidatedQuery, readValidatedBody } from "h3";
|
|
|
3
3
|
import { useStorage } from "nitropack/runtime";
|
|
4
4
|
import { stringify } from "csv-stringify/sync";
|
|
5
5
|
import syncGitContent from "../../utils/syncGitContent.js";
|
|
6
|
+
import { normalizeContentKey } from "../../utils/contentKey.js";
|
|
7
|
+
import { toGitErrorMessage } from "../../utils/gitVersioning.js";
|
|
6
8
|
const querySchema = z.object({
|
|
7
9
|
path: z.string().min(1)
|
|
8
10
|
});
|
|
@@ -16,18 +18,18 @@ const bodySchema = z.object({
|
|
|
16
18
|
export default defineEventHandler(async (event) => {
|
|
17
19
|
const { path } = await getValidatedQuery(event, (query) => querySchema.parse(query));
|
|
18
20
|
const { table, commitMessage } = await readValidatedBody(event, (body) => bodySchema.parse(body));
|
|
19
|
-
const
|
|
21
|
+
const contentKey = normalizeContentKey(path);
|
|
20
22
|
const content = stringify(table.rows, {
|
|
21
23
|
header: true,
|
|
22
24
|
columns: table.headers,
|
|
23
25
|
delimiter: ";"
|
|
24
26
|
});
|
|
25
27
|
const storage = useStorage("content");
|
|
26
|
-
await storage.setItem(
|
|
28
|
+
await storage.setItem(contentKey, content);
|
|
27
29
|
try {
|
|
28
|
-
await syncGitContent(commitMessage, [
|
|
30
|
+
await syncGitContent(commitMessage, [contentKey]);
|
|
29
31
|
} catch (error) {
|
|
30
|
-
console.error("Git-Fehler:", error);
|
|
32
|
+
console.error("Git-Fehler:", toGitErrorMessage(error, "Git sync failed"));
|
|
31
33
|
}
|
|
32
34
|
return { success: true };
|
|
33
35
|
});
|
|
@@ -2,18 +2,20 @@ import { z } from "zod";
|
|
|
2
2
|
import { defineEventHandler, getValidatedQuery } from "h3";
|
|
3
3
|
import { useStorage } from "nitropack/runtime";
|
|
4
4
|
import syncGitContent from "../../utils/syncGitContent.js";
|
|
5
|
+
import { normalizeContentKey } from "../../utils/contentKey.js";
|
|
6
|
+
import { toGitErrorMessage } from "../../utils/gitVersioning.js";
|
|
5
7
|
const querySchema = z.object({
|
|
6
8
|
path: z.string().min(1)
|
|
7
9
|
});
|
|
8
10
|
export default defineEventHandler(async (event) => {
|
|
9
11
|
const { path } = await getValidatedQuery(event, (query) => querySchema.parse(query));
|
|
10
|
-
const
|
|
12
|
+
const contentKey = normalizeContentKey(path);
|
|
11
13
|
const storage = useStorage("content");
|
|
12
|
-
await storage.removeItem(
|
|
14
|
+
await storage.removeItem(contentKey);
|
|
13
15
|
try {
|
|
14
|
-
await syncGitContent("Datei gel\xF6scht", [
|
|
16
|
+
await syncGitContent("Datei gel\xF6scht", [contentKey]);
|
|
15
17
|
} catch (error) {
|
|
16
|
-
console.error("Git-Fehler:", error);
|
|
18
|
+
console.error("Git-Fehler:", toGitErrorMessage(error, "Git sync failed"));
|
|
17
19
|
}
|
|
18
20
|
return { success: true };
|
|
19
21
|
});
|
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { defineEventHandler, getValidatedQuery } from "h3";
|
|
3
3
|
import { useStorage } from "nitropack/runtime";
|
|
4
|
+
import { normalizeContentKey } from "../../utils/contentKey.js";
|
|
5
|
+
import { isCsvPath, isImagePath, isJsonPath, isPdfPath, toFileExtension } from "../../../shared/contentFiles.js";
|
|
4
6
|
const querySchema = z.object({
|
|
5
7
|
path: z.string().min(1)
|
|
6
8
|
});
|
|
7
9
|
export default defineEventHandler(async (event) => {
|
|
8
10
|
const { path } = await getValidatedQuery(event, (query) => querySchema.parse(query));
|
|
9
|
-
const
|
|
11
|
+
const contentKey = normalizeContentKey(path);
|
|
10
12
|
const storage = useStorage("content");
|
|
11
|
-
const file = await storage.getItemRaw(
|
|
12
|
-
const isImage =
|
|
13
|
-
const isPdf =
|
|
14
|
-
const isJson =
|
|
15
|
-
const isCSV =
|
|
13
|
+
const file = await storage.getItemRaw(contentKey);
|
|
14
|
+
const isImage = isImagePath(contentKey);
|
|
15
|
+
const isPdf = isPdfPath(contentKey);
|
|
16
|
+
const isJson = isJsonPath(contentKey);
|
|
17
|
+
const isCSV = isCsvPath(contentKey);
|
|
16
18
|
if (isImage) {
|
|
17
|
-
|
|
19
|
+
const extension = toFileExtension(contentKey).slice(1);
|
|
20
|
+
event.node.res.setHeader("Content-Type", "image/" + extension);
|
|
18
21
|
} else if (isPdf) {
|
|
19
22
|
event.node.res.setHeader("Content-Type", "application/pdf");
|
|
20
23
|
} else if (isJson || isCSV) {
|
|
@@ -22,6 +25,6 @@ export default defineEventHandler(async (event) => {
|
|
|
22
25
|
} else {
|
|
23
26
|
event.node.res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
24
27
|
}
|
|
25
|
-
event.node.res.setHeader("Content-Disposition", `attachment; filename="${
|
|
28
|
+
event.node.res.setHeader("Content-Disposition", `attachment; filename="${contentKey.split(":").pop()}"`);
|
|
26
29
|
return file;
|
|
27
30
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createError, defineEventHandler } from "h3";
|
|
2
|
-
import { getCounterpartBranch, getCurrentBranchName, hasRemoteBranch, isSupportedWebsiteBranch, isVersioningEnabled } from "../../utils/gitVersioning.js";
|
|
2
|
+
import { getCounterpartBranch, getCurrentBranchName, hasRemoteBranch, isSupportedWebsiteBranch, isVersioningEnabled, toGitErrorMessage } from "../../utils/gitVersioning.js";
|
|
3
3
|
export default defineEventHandler(async () => {
|
|
4
4
|
try {
|
|
5
5
|
if (!isVersioningEnabled()) {
|
|
@@ -36,9 +36,10 @@ export default defineEventHandler(async () => {
|
|
|
36
36
|
if (error?.statusCode && error?.statusMessage) {
|
|
37
37
|
throw error;
|
|
38
38
|
}
|
|
39
|
+
const safeMessage = toGitErrorMessage(error, "Failed to get current Git branch");
|
|
39
40
|
throw createError({
|
|
40
41
|
statusCode: 500,
|
|
41
|
-
statusMessage:
|
|
42
|
+
statusMessage: safeMessage
|
|
42
43
|
});
|
|
43
44
|
}
|
|
44
45
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { createError, defineEventHandler, getValidatedQuery } from "h3";
|
|
3
|
-
import { getGitHistoryPage, isVersioningEnabled } from "../../utils/gitVersioning.js";
|
|
3
|
+
import { getGitHistoryPage, isVersioningEnabled, toGitErrorMessage } from "../../utils/gitVersioning.js";
|
|
4
4
|
const querySchema = z.object({
|
|
5
5
|
page: z.coerce.number().int().min(1).default(1),
|
|
6
6
|
perPage: z.coerce.number().int().min(1).max(100).default(25)
|
|
@@ -19,9 +19,10 @@ export default defineEventHandler(async (event) => {
|
|
|
19
19
|
if (error?.statusCode && error?.statusMessage) {
|
|
20
20
|
throw error;
|
|
21
21
|
}
|
|
22
|
+
const safeMessage = toGitErrorMessage(error, "Failed to load Git history");
|
|
22
23
|
throw createError({
|
|
23
24
|
statusCode: 500,
|
|
24
|
-
statusMessage:
|
|
25
|
+
statusMessage: safeMessage
|
|
25
26
|
});
|
|
26
27
|
}
|
|
27
28
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createError, defineEventHandler } from "h3";
|
|
2
|
-
import { getBranchUpdateStatus, isVersioningEnabled } from "../../utils/gitVersioning.js";
|
|
2
|
+
import { getBranchUpdateStatus, isVersioningEnabled, toGitErrorMessage } from "../../utils/gitVersioning.js";
|
|
3
3
|
export default defineEventHandler(async () => {
|
|
4
4
|
try {
|
|
5
5
|
if (!isVersioningEnabled()) {
|
|
@@ -13,9 +13,10 @@ export default defineEventHandler(async () => {
|
|
|
13
13
|
if (error?.statusCode && error?.statusMessage) {
|
|
14
14
|
throw error;
|
|
15
15
|
}
|
|
16
|
+
const safeMessage = toGitErrorMessage(error, "Failed to get Git update status");
|
|
16
17
|
throw createError({
|
|
17
18
|
statusCode: 500,
|
|
18
|
-
statusMessage:
|
|
19
|
+
statusMessage: safeMessage
|
|
19
20
|
});
|
|
20
21
|
}
|
|
21
22
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createError, defineEventHandler } from "h3";
|
|
2
|
-
import { isVersioningEnabled, mergeCounterpartBranchIntoCurrent } from "../../utils/gitVersioning.js";
|
|
2
|
+
import { isVersioningEnabled, mergeCounterpartBranchIntoCurrent, toGitErrorMessage } from "../../utils/gitVersioning.js";
|
|
3
3
|
function toStatusCode(message) {
|
|
4
4
|
if (/Versioning feature is disabled/i.test(message)) {
|
|
5
5
|
return 404;
|
|
@@ -36,7 +36,7 @@ export default defineEventHandler(async () => {
|
|
|
36
36
|
if (error?.statusCode && error?.statusMessage) {
|
|
37
37
|
throw error;
|
|
38
38
|
}
|
|
39
|
-
const message = error
|
|
39
|
+
const message = toGitErrorMessage(error, "Failed to update branch");
|
|
40
40
|
throw createError({
|
|
41
41
|
statusCode: toStatusCode(message),
|
|
42
42
|
statusMessage: message
|
|
@@ -3,13 +3,18 @@ import { createError, defineEventHandler, getValidatedQuery, readMultipartFormDa
|
|
|
3
3
|
import { useStorage } from "nitropack/runtime";
|
|
4
4
|
import sharp from "sharp";
|
|
5
5
|
import syncGitContent from "../../utils/syncGitContent.js";
|
|
6
|
+
import { normalizeContentKey } from "../../utils/contentKey.js";
|
|
7
|
+
import { IMAGE_EXTENSIONS, hasAllowedExtension, toFileExtension } from "../../../shared/contentFiles.js";
|
|
8
|
+
import { assertUploadSize, getMaxUploadBytes } from "../../utils/uploadGuard.js";
|
|
9
|
+
import { toGitErrorMessage } from "../../utils/gitVersioning.js";
|
|
6
10
|
const querySchema = z.object({
|
|
7
11
|
path: z.string().min(1)
|
|
8
12
|
});
|
|
9
13
|
export default defineEventHandler(async (event) => {
|
|
10
14
|
const form = await readMultipartFormData(event);
|
|
15
|
+
const maxUploadBytes = getMaxUploadBytes();
|
|
11
16
|
const { path } = await getValidatedQuery(event, (query) => querySchema.parse(query));
|
|
12
|
-
const
|
|
17
|
+
const contentKey = normalizeContentKey(path);
|
|
13
18
|
if (!form) {
|
|
14
19
|
throw createError({
|
|
15
20
|
statusCode: 400,
|
|
@@ -29,21 +34,20 @@ export default defineEventHandler(async (event) => {
|
|
|
29
34
|
statusMessage: "Invalid file upload"
|
|
30
35
|
});
|
|
31
36
|
}
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
const targetExtension = decodedPath.toLowerCase().slice(decodedPath.lastIndexOf("."));
|
|
35
|
-
if (!allowedExtensions.includes(targetExtension)) {
|
|
37
|
+
const targetExtension = toFileExtension(contentKey);
|
|
38
|
+
if (!hasAllowedExtension(contentKey, IMAGE_EXTENSIONS)) {
|
|
36
39
|
throw createError({
|
|
37
40
|
statusCode: 400,
|
|
38
41
|
statusMessage: "Invalid target file type. Only JPG, JPEG, PNG, GIF, WEBP files are allowed."
|
|
39
42
|
});
|
|
40
43
|
}
|
|
41
|
-
if (!
|
|
44
|
+
if (!hasAllowedExtension(file.filename, IMAGE_EXTENSIONS)) {
|
|
42
45
|
throw createError({
|
|
43
46
|
statusCode: 400,
|
|
44
47
|
statusMessage: "Invalid file type. Only JPG, JPEG, PNG, GIF, WEBP files are allowed."
|
|
45
48
|
});
|
|
46
49
|
}
|
|
50
|
+
assertUploadSize(file.data, maxUploadBytes);
|
|
47
51
|
const image = sharp(file.data);
|
|
48
52
|
const metadata = await image.metadata();
|
|
49
53
|
if (metadata.width && metadata.height) {
|
|
@@ -84,11 +88,11 @@ export default defineEventHandler(async (event) => {
|
|
|
84
88
|
});
|
|
85
89
|
}
|
|
86
90
|
const outputBuffer = await image.toBuffer();
|
|
87
|
-
await useStorage("content").setItemRaw(
|
|
91
|
+
await useStorage("content").setItemRaw(contentKey, outputBuffer);
|
|
88
92
|
try {
|
|
89
|
-
await syncGitContent("Bild ersetzt", [
|
|
93
|
+
await syncGitContent("Bild ersetzt", [contentKey]);
|
|
90
94
|
} catch (error) {
|
|
91
|
-
console.error("Git-Fehler:", error);
|
|
95
|
+
console.error("Git-Fehler:", toGitErrorMessage(error, "Git sync failed"));
|
|
92
96
|
}
|
|
93
|
-
return { success: true, path:
|
|
97
|
+
return { success: true, path: contentKey };
|
|
94
98
|
});
|
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
import z from "zod";
|
|
2
2
|
import { useStorage } from "nitropack/runtime";
|
|
3
3
|
import { defineEventHandler, getValidatedQuery } from "h3";
|
|
4
|
+
import { normalizeContentPrefix } from "../../utils/contentKey.js";
|
|
5
|
+
import { isImagePath } from "../../../shared/contentFiles.js";
|
|
6
|
+
function alphaSort(a, b) {
|
|
7
|
+
return a.localeCompare(b, void 0, { numeric: true, sensitivity: "base" });
|
|
8
|
+
}
|
|
4
9
|
const querySchema = z.object({
|
|
5
10
|
path: z.string().optional(),
|
|
6
11
|
type: z.enum(["image"]).optional()
|
|
7
12
|
});
|
|
8
13
|
export default defineEventHandler(async (event) => {
|
|
9
14
|
const { path, type } = await getValidatedQuery(event, (query) => querySchema.parse(query));
|
|
10
|
-
const
|
|
15
|
+
const contentPrefix = normalizeContentPrefix(path);
|
|
11
16
|
const storage = useStorage("content");
|
|
12
|
-
const keys = await storage.getKeys(
|
|
13
|
-
const keysWithoutPath =
|
|
17
|
+
const keys = await storage.getKeys(contentPrefix);
|
|
18
|
+
const keysWithoutPath = contentPrefix ? keys.map((key) => key.replace(contentPrefix + ":", "")) : keys;
|
|
14
19
|
const files = keysWithoutPath.filter((key) => !key.includes(":"));
|
|
15
|
-
const filteredFiles = type === "image" ? files.filter((file) => file
|
|
20
|
+
const filteredFiles = type === "image" ? files.filter((file) => isImagePath(file)) : files;
|
|
16
21
|
const dirs = keysWithoutPath.filter((key) => key.includes(":")).map((key) => key.split(":")[0]);
|
|
17
22
|
const uniqueDirs = Array.from(new Set(dirs));
|
|
18
23
|
return {
|
|
19
|
-
files: filteredFiles,
|
|
20
|
-
dirs: uniqueDirs
|
|
24
|
+
files: [...filteredFiles].sort(alphaSort),
|
|
25
|
+
dirs: [...uniqueDirs].sort(alphaSort)
|
|
21
26
|
};
|
|
22
27
|
});
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { useRuntimeConfig } from "nitropack/runtime";
|
|
2
2
|
import { createError, defineEventHandler, readValidatedBody, setCookie } from "h3";
|
|
3
3
|
import z from "zod";
|
|
4
|
+
import { ADMIN_AUTH_COOKIE_NAME, getAuthCookieOptions } from "../../utils/authCookie.js";
|
|
5
|
+
import { assertLoginNotRateLimited, clearFailedLoginAttempts, recordFailedLoginAttempt } from "../../utils/loginRateLimit.js";
|
|
4
6
|
const bodySchema = z.object({
|
|
5
7
|
adminAuthKey: z.string()
|
|
6
8
|
});
|
|
7
9
|
export default defineEventHandler(async (event) => {
|
|
8
10
|
const { mktcms: { adminAuthKey } } = useRuntimeConfig();
|
|
11
|
+
assertLoginNotRateLimited(event);
|
|
9
12
|
const body = await readValidatedBody(event, (body2) => bodySchema.parse(body2));
|
|
10
13
|
if (body.adminAuthKey !== adminAuthKey.toString()) {
|
|
14
|
+
recordFailedLoginAttempt(event);
|
|
11
15
|
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
|
12
16
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
maxAge: 7 * 24 * 60 * 60
|
|
16
|
-
});
|
|
17
|
+
clearFailedLoginAttempts(event);
|
|
18
|
+
setCookie(event, ADMIN_AUTH_COOKIE_NAME, adminAuthKey.toString(), getAuthCookieOptions(event));
|
|
17
19
|
return { message: "Login successful" };
|
|
18
20
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineEventHandler, deleteCookie } from "h3";
|
|
2
|
+
import { ADMIN_AUTH_COOKIE_NAME, getAuthCookieOptions } from "../../utils/authCookie.js";
|
|
2
3
|
export default defineEventHandler(async (event) => {
|
|
3
|
-
deleteCookie(event,
|
|
4
|
+
deleteCookie(event, ADMIN_AUTH_COOKIE_NAME, getAuthCookieOptions(event));
|
|
4
5
|
event.node.res.writeHead(302, { Location: "/admin/login" });
|
|
5
6
|
event.node.res.end();
|
|
6
7
|
});
|
|
@@ -2,14 +2,15 @@ import { z } from "zod";
|
|
|
2
2
|
import { createError, defineEventHandler, getValidatedQuery } from "h3";
|
|
3
3
|
import { useStorage } from "nitropack/runtime";
|
|
4
4
|
import { parseFrontmatter } from "../../utils/parseFrontmatter.js";
|
|
5
|
+
import { normalizeContentKey } from "../../utils/contentKey.js";
|
|
5
6
|
const querySchema = z.object({
|
|
6
7
|
path: z.string().min(1)
|
|
7
8
|
});
|
|
8
9
|
export default defineEventHandler(async (event) => {
|
|
9
10
|
const { path } = await getValidatedQuery(event, (query) => querySchema.parse(query));
|
|
10
|
-
const
|
|
11
|
+
const contentKey = normalizeContentKey(path);
|
|
11
12
|
const storage = useStorage("content");
|
|
12
|
-
const file = await storage.getItem(
|
|
13
|
+
const file = await storage.getItem(contentKey);
|
|
13
14
|
if (!file) {
|
|
14
15
|
throw createError({
|
|
15
16
|
statusCode: 404,
|