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.
Files changed (49) hide show
  1. package/README.md +5 -3
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +8 -5
  4. package/dist/runtime/app/components/content/editor/txt.vue +21 -1
  5. package/dist/runtime/app/components/content/index.vue +5 -17
  6. package/dist/runtime/app/components/content/upload.vue +7 -3
  7. package/dist/runtime/app/composables/useFileType.js +6 -6
  8. package/dist/runtime/app/pages/admin/login.vue +34 -7
  9. package/dist/runtime/server/api/admin/blob.js +4 -3
  10. package/dist/runtime/server/api/admin/csv.js +3 -2
  11. package/dist/runtime/server/api/admin/csv.post.js +6 -4
  12. package/dist/runtime/server/api/admin/delete.js +6 -4
  13. package/dist/runtime/server/api/admin/download.js +11 -8
  14. package/dist/runtime/server/api/admin/git-branch.js +3 -2
  15. package/dist/runtime/server/api/admin/git-history.js +3 -2
  16. package/dist/runtime/server/api/admin/git-update-status.js +3 -2
  17. package/dist/runtime/server/api/admin/git-update.post.js +2 -2
  18. package/dist/runtime/server/api/admin/image.post.js +14 -10
  19. package/dist/runtime/server/api/admin/list.js +11 -6
  20. package/dist/runtime/server/api/admin/login.js +6 -4
  21. package/dist/runtime/server/api/admin/logout.js +2 -1
  22. package/dist/runtime/server/api/admin/md.js +3 -2
  23. package/dist/runtime/server/api/admin/md.post.js +6 -4
  24. package/dist/runtime/server/api/admin/pdf.post.js +13 -9
  25. package/dist/runtime/server/api/admin/txt.js +3 -2
  26. package/dist/runtime/server/api/admin/txt.post.js +13 -4
  27. package/dist/runtime/server/api/admin/upload.js +10 -6
  28. package/dist/runtime/server/api/content/[path].js +14 -12
  29. package/dist/runtime/server/api/content/list.js +9 -7
  30. package/dist/runtime/server/middleware/auth.js +2 -1
  31. package/dist/runtime/server/utils/authCookie.d.ts +9 -0
  32. package/dist/runtime/server/utils/authCookie.js +12 -0
  33. package/dist/runtime/server/utils/contentKey.d.ts +2 -0
  34. package/dist/runtime/server/utils/contentKey.js +67 -0
  35. package/dist/runtime/server/utils/gitErrorSanitization.d.ts +1 -0
  36. package/dist/runtime/server/utils/gitErrorSanitization.js +17 -0
  37. package/dist/runtime/server/utils/gitVersioning.d.ts +1 -2
  38. package/dist/runtime/server/utils/gitVersioning.js +2 -13
  39. package/dist/runtime/server/utils/loginRateLimit.d.ts +4 -0
  40. package/dist/runtime/server/utils/loginRateLimit.js +72 -0
  41. package/dist/runtime/server/utils/uploadGuard.d.ts +2 -0
  42. package/dist/runtime/server/utils/uploadGuard.js +20 -0
  43. package/dist/runtime/shared/contentFiles.d.ts +15 -0
  44. package/dist/runtime/shared/contentFiles.js +38 -0
  45. package/package.json +1 -1
  46. package/dist/runtime/app/composables/useImport.d.ts +0 -6
  47. package/dist/runtime/app/composables/useImport.js +0 -37
  48. package/dist/runtime/server/api/admin/import.d.ts +0 -5
  49. package/dist/runtime/server/api/admin/import.js +0 -86
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
- # Simple CMS module for Nuxt
1
+ # MktCMS
2
+
3
+ A file based CMS for self-hosted Nuxt apps.
2
4
 
3
5
  ![Screenshot of Admin UI](https://raw.githubusercontent.com/mktcode/mktcms/refs/heads/main/docs/screenshot.png)
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 probably migrate more and and more (MDC already supported), and keep this repo as a custom nuxt template for my projects. If you find it useful, feel free to use it as well and contribute to it.
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 files
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mktcms",
3
3
  "configKey": "mktcms",
4
- "version": "0.2.12",
4
+ "version": "0.2.14",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
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
- import useImport from "../../composables/useImport";
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
- <button
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=".pdf,.jpg,.jpeg,.png,.gif,.webp,.md,.docx,.txt,.csv,.json"
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=".jpg,.jpeg,.png,.gif,.webp"
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=".pdf"
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 normalizedPath = path.toLowerCase();
3
- const isImage = normalizedPath.match(/\.(png|jpg|jpeg|gif|webp)$/) !== null;
4
- const isPdf = normalizedPath.endsWith(".pdf");
5
- const isMarkdown = normalizedPath.endsWith(".md");
6
- const isCsv = normalizedPath.endsWith(".csv");
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
- await $fetch("/api/admin/login", {
32
- method: "POST",
33
- body: {
34
- adminAuthKey: adminAuthKey.value
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
- wasHere.value = true;
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 decodedPath = decodeURIComponent(path);
11
+ const contentKey = normalizeContentKey(path);
11
12
  const storage = useStorage("content");
12
- const file = await storage.getItemRaw(decodedPath);
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 = decodedPath.split(".").pop()?.toLowerCase() || "bin";
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 decodedPath = decodeURIComponent(path);
11
+ const contentKey = normalizeContentKey(path);
11
12
  const storage = useStorage("content");
12
- const file = await storage.getItem(decodedPath);
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 decodedPath = decodeURIComponent(path);
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(decodedPath, content);
28
+ await storage.setItem(contentKey, content);
27
29
  try {
28
- await syncGitContent(commitMessage, [decodedPath]);
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 decodedPath = decodeURIComponent(path);
12
+ const contentKey = normalizeContentKey(path);
11
13
  const storage = useStorage("content");
12
- await storage.removeItem(decodedPath);
14
+ await storage.removeItem(contentKey);
13
15
  try {
14
- await syncGitContent("Datei gel\xF6scht", [decodedPath]);
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 decodedPath = decodeURIComponent(path);
11
+ const contentKey = normalizeContentKey(path);
10
12
  const storage = useStorage("content");
11
- const file = await storage.getItemRaw(decodedPath);
12
- const isImage = decodedPath.match(/\.(png|jpg|jpeg|gif|webp)$/i);
13
- const isPdf = decodedPath.endsWith(".pdf");
14
- const isJson = decodedPath.endsWith(".json");
15
- const isCSV = decodedPath.endsWith(".csv");
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
- event.node.res.setHeader("Content-Type", "image/" + decodedPath.split(".").pop()?.toLowerCase());
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="${decodedPath.split(":").pop()}"`);
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: error?.message || "Failed to get current Git branch"
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: error?.message || "Failed to load Git history"
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: error?.message || "Failed to get Git update status"
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?.message || "Failed to update branch";
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 decodedPath = decodeURIComponent(path);
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 allowedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
33
- const fileExtension = file.filename.toLowerCase().slice(file.filename.lastIndexOf("."));
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 (!allowedExtensions.includes(fileExtension)) {
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(decodedPath, outputBuffer);
91
+ await useStorage("content").setItemRaw(contentKey, outputBuffer);
88
92
  try {
89
- await syncGitContent("Bild ersetzt", [decodedPath]);
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: decodedPath };
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 decodedPath = path ? decodeURIComponent(path) : "";
15
+ const contentPrefix = normalizeContentPrefix(path);
11
16
  const storage = useStorage("content");
12
- const keys = await storage.getKeys(decodedPath);
13
- const keysWithoutPath = decodedPath ? keys.map((key) => key.replace(decodedPath + ":", "")) : keys;
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.match(/\.(png|jpg|jpeg|gif|webp)$/i)) : files;
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
- setCookie(event, "mktcms_admin_auth_key", adminAuthKey.toString(), {
14
- httpOnly: true,
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, "mktcms_admin_auth_key");
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 decodedPath = decodeURIComponent(path);
11
+ const contentKey = normalizeContentKey(path);
11
12
  const storage = useStorage("content");
12
- const file = await storage.getItem(decodedPath);
13
+ const file = await storage.getItem(contentKey);
13
14
  if (!file) {
14
15
  throw createError({
15
16
  statusCode: 404,