diff --git a/apps/frontend/src/components/ui/servers/PanelTerminal.vue b/apps/frontend/src/components/ui/servers/PanelTerminal.vue
index 138baa507..02d7edb90 100644
--- a/apps/frontend/src/components/ui/servers/PanelTerminal.vue
+++ b/apps/frontend/src/components/ui/servers/PanelTerminal.vue
@@ -260,7 +260,25 @@
@@ -272,6 +290,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import { useDebounceFn } from "@vueuse/core";
import { NewModal } from "@modrinth/ui";
import ButtonStyled from "@modrinth/ui/src/components/base/ButtonStyled.vue";
+import DOMPurify from "dompurify";
import { usePyroConsole } from "~/store/console.ts";
const { $cosmetics } = useNuxtApp();
@@ -984,6 +1003,38 @@ const jumpToLine = (line: string, event?: MouseEvent) => {
});
};
+const sanitizeUrl = (url: string): string => {
+ try {
+ const parsed = new URL(url);
+ if (!["http:", "https:"].includes(parsed.protocol)) {
+ return "#";
+ }
+ return parsed.toString();
+ } catch {
+ return "#";
+ }
+};
+
+const detectedLinks = computed(() => {
+ const urlRegex = /(https?:\/\/[^\s,<]+(?=[,\s<]|$))/g;
+ const matches = [...selectedLog.value.matchAll(urlRegex)].map((match) => match[0]);
+ return matches.filter((url) => sanitizeUrl(url) !== "#");
+});
+
+const processedLogWithLinks = computed(() => {
+ const urlRegex = /(https?:\/\/[^\s,<]+(?=[,\s<]|$))/g;
+ const sanitizedLog = DOMPurify.sanitize(selectedLog.value, {
+ ALLOWED_TAGS: [],
+ ALLOWED_ATTR: [],
+ });
+
+ return sanitizedLog.replace(urlRegex, (url) => {
+ const safeUrl = sanitizeUrl(url);
+ if (safeUrl === "#") return url;
+ return `${url}`;
+ });
+});
+
watch(
() => pyroConsole.filteredOutput.value,
() => {