Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 12 additions & 13 deletions src/backend/database/routes/dashboard-service-links-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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" });
Expand All @@ -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(),
})
Expand Down Expand Up @@ -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" });
Expand All @@ -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" });
Expand Down
28 changes: 28 additions & 0 deletions src/backend/database/routes/service-link-url.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
16 changes: 16 additions & 0 deletions src/backend/database/routes/service-link-url.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
14 changes: 12 additions & 2 deletions src/ui/api/dashboard-api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { dashboardApi, handleApiError } from "@/main-axios";
import { normalizeServiceLinkUrl } from "@/lib/service-link-url";

// DASHBOARD API
// ============================================================================
Expand Down Expand Up @@ -105,7 +106,10 @@ export async function createServiceLink(
url: string,
): Promise<ServiceLink> {
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");
Expand All @@ -128,7 +132,13 @@ export async function updateServiceLink(
updates: { label?: string; url?: string },
): Promise<ServiceLink> {
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");
Expand Down
33 changes: 22 additions & 11 deletions src/ui/dashboard/DashboardTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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"}`}
Expand All @@ -754,6 +760,11 @@ function ServiceLinksCard({
{t("dashboardTab.serviceLinksInvalidUrl")}
</div>
)}
{addError && (
<div className="px-4 pb-2 text-[10px] text-destructive shrink-0">
{addError}
</div>
)}
</Card>
);
}
Expand Down
16 changes: 16 additions & 0 deletions src/ui/lib/service-link-url.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
3 changes: 2 additions & 1 deletion src/ui/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Loading