diff --git a/src/backend/database/routes/dashboard-service-links-routes.ts b/src/backend/database/routes/dashboard-service-links-routes.ts index e7dacebec..a8909a3db 100644 --- a/src/backend/database/routes/dashboard-service-links-routes.ts +++ b/src/backend/database/routes/dashboard-service-links-routes.ts @@ -5,19 +5,14 @@ import { dashboardLogger } from "../../utils/logger.js"; import { db } from "../db/index.js"; import { dashboardServiceLinks } from "../db/schema.js"; import { isNonEmptyString } from "./host-normalizers.js"; +import { + isValidServiceLinkUrl, + normalizeServiceLinkUrl, +} from "./service-link-url.js"; import express from "express"; export const dashboardServiceLinksRouter = express.Router(); -function isValidUrl(url: string): boolean { - try { - const parsed = new URL(url); - return parsed.protocol === "http:" || parsed.protocol === "https:"; - } catch { - return false; - } -} - /** * @openapi * /service-links: @@ -84,7 +79,8 @@ dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => { if (!isNonEmptyString(label) || !isNonEmptyString(url)) { return res.status(400).json({ error: "label and url are required" }); } - if (!isValidUrl(url)) { + const normalizedUrl = normalizeServiceLinkUrl(url); + if (!isValidServiceLinkUrl(normalizedUrl)) { return res .status(400) .json({ error: "url must be a valid http or https URL" }); @@ -105,7 +101,7 @@ dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => { .values({ userId, label: label.trim(), - url: url.trim(), + url: normalizedUrl, order: nextOrder, createdAt: new Date().toISOString(), }) @@ -233,7 +229,10 @@ dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => { if (isNaN(id)) { return res.status(400).json({ error: "Invalid id" }); } - if (url !== undefined && !isValidUrl(url)) { + const normalizedUrl = isNonEmptyString(url) + ? normalizeServiceLinkUrl(url) + : undefined; + if (normalizedUrl !== undefined && !isValidServiceLinkUrl(normalizedUrl)) { return res .status(400) .json({ error: "url must be a valid http or https URL" }); @@ -256,7 +255,7 @@ dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => { const updates: Partial<{ label: string; url: string }> = {}; if (isNonEmptyString(label)) updates.label = label.trim(); - if (isNonEmptyString(url)) updates.url = url.trim(); + if (normalizedUrl !== undefined) updates.url = normalizedUrl; if (Object.keys(updates).length === 0) { return res.status(400).json({ error: "Nothing to update" }); diff --git a/src/backend/database/routes/service-link-url.test.ts b/src/backend/database/routes/service-link-url.test.ts new file mode 100644 index 000000000..353d4da64 --- /dev/null +++ b/src/backend/database/routes/service-link-url.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { + isValidServiceLinkUrl, + normalizeServiceLinkUrl, +} from "./service-link-url.js"; + +describe("service link URL handling", () => { + it("keeps explicit http and https URLs", () => { + expect(normalizeServiceLinkUrl("https://example.com")).toBe( + "https://example.com", + ); + expect(normalizeServiceLinkUrl("http://192.168.1.10:8080")).toBe( + "http://192.168.1.10:8080", + ); + }); + + it("adds http to bare service addresses", () => { + expect(normalizeServiceLinkUrl("192.168.1.10:8080")).toBe( + "http://192.168.1.10:8080", + ); + expect(normalizeServiceLinkUrl("termix.local")).toBe("http://termix.local"); + }); + + it("rejects unsupported schemes", () => { + expect(isValidServiceLinkUrl("ssh://example.com")).toBe(false); + expect(isValidServiceLinkUrl("javascript:alert(1)")).toBe(false); + }); +}); diff --git a/src/backend/database/routes/service-link-url.ts b/src/backend/database/routes/service-link-url.ts new file mode 100644 index 000000000..cb493ae2a --- /dev/null +++ b/src/backend/database/routes/service-link-url.ts @@ -0,0 +1,16 @@ +export function normalizeServiceLinkUrl(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ""; + return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) + ? trimmed + : `http://${trimmed}`; +} + +export function isValidServiceLinkUrl(value: string): boolean { + try { + const parsed = new URL(normalizeServiceLinkUrl(value)); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} diff --git a/src/ui/api/dashboard-api.ts b/src/ui/api/dashboard-api.ts index 281eda407..1e5abfc0e 100644 --- a/src/ui/api/dashboard-api.ts +++ b/src/ui/api/dashboard-api.ts @@ -1,4 +1,5 @@ import { dashboardApi, handleApiError } from "@/main-axios"; +import { normalizeServiceLinkUrl } from "@/lib/service-link-url"; // DASHBOARD API // ============================================================================ @@ -105,7 +106,10 @@ export async function createServiceLink( url: string, ): Promise { try { - const response = await dashboardApi.post("/service-links", { label, url }); + const response = await dashboardApi.post("/service-links", { + label, + url: normalizeServiceLinkUrl(url), + }); return response.data; } catch (error) { throw handleApiError(error, "create service link"); @@ -128,7 +132,13 @@ export async function updateServiceLink( updates: { label?: string; url?: string }, ): Promise { try { - const response = await dashboardApi.put(`/service-links/${id}`, updates); + const response = await dashboardApi.put(`/service-links/${id}`, { + ...updates, + url: + updates.url !== undefined + ? normalizeServiceLinkUrl(updates.url) + : undefined, + }); return response.data; } catch (error) { throw handleApiError(error, "update service link"); diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index 201af7ed5..80ec56c58 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -64,6 +64,10 @@ import { import { useServerStatus } from "@/lib/ServerStatusContext"; import { sshHostToHost } from "@/sidebar/HostManagerData"; import { getDefaultConnectionTab } from "@/lib/host-connection-tabs"; +import { + isValidServiceLinkUrl, + normalizeServiceLinkUrl, +} from "@/lib/service-link-url"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -653,28 +657,29 @@ function ServiceLinksCard({ const [label, setLabel] = useState(""); const [url, setUrl] = useState(""); const [urlError, setUrlError] = useState(false); + const [addError, setAddError] = useState(""); const [adding, setAdding] = useState(false); const handleAdd = async () => { - let valid = true; - try { - const parsed = new URL(url); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - valid = false; - } - } catch { - valid = false; - } - if (!valid) { + const normalizedUrl = normalizeServiceLinkUrl(url); + if (!isValidServiceLinkUrl(normalizedUrl)) { setUrlError(true); + setAddError(""); return; } setUrlError(false); + setAddError(""); setAdding(true); try { - await onAdd(label.trim(), url.trim()); + await onAdd(label.trim(), normalizedUrl); setLabel(""); setUrl(""); + } catch (error) { + setAddError( + error instanceof Error + ? error.message + : t("dashboardTab.serviceLinksAddFailed"), + ); } finally { setAdding(false); } @@ -736,6 +741,7 @@ function ServiceLinksCard({ onChange={(e) => { setUrl(e.target.value); setUrlError(false); + setAddError(""); }} placeholder={t("dashboardTab.serviceLinksUrlPlaceholder")} className={`flex-[2] min-w-0 text-xs bg-transparent border px-2 py-1 focus:outline-none ${urlError ? "border-destructive" : "border-border focus:border-accent-brand/60"}`} @@ -754,6 +760,11 @@ function ServiceLinksCard({ {t("dashboardTab.serviceLinksInvalidUrl")} )} + {addError && ( +
+ {addError} +
+ )} ); } diff --git a/src/ui/lib/service-link-url.ts b/src/ui/lib/service-link-url.ts new file mode 100644 index 000000000..cb493ae2a --- /dev/null +++ b/src/ui/lib/service-link-url.ts @@ -0,0 +1,16 @@ +export function normalizeServiceLinkUrl(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ""; + return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) + ? trimmed + : `http://${trimmed}`; +} + +export function isValidServiceLinkUrl(value: string): boolean { + try { + const parsed = new URL(normalizeServiceLinkUrl(value)); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index b05d30265..483c344de 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -2076,7 +2076,8 @@ "serviceLinksAdd": "Add", "serviceLinksLabelPlaceholder": "My Service", "serviceLinksUrlPlaceholder": "http://192.168.1.10:8080", - "serviceLinksInvalidUrl": "Must be a valid http or https URL", + "serviceLinksInvalidUrl": "Enter a valid web address", + "serviceLinksAddFailed": "Failed to add service link", "disk": "Disk", "viewServerDetails": "View server details" },