diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2850e95c..69c3051c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@
- Fix Firefox open file that stopped working due to react upgrade
- Fix issue with missing bottom error panel
- Fixed headers in left panes (Layers list and Layer editor) to remain visible when scrolling
+- Fix error when using a source from localhost
- _...Add new stuff here..._
## 3.0.0
diff --git a/src/components/InputUrl.tsx b/src/components/InputUrl.tsx
index 3cf3810d..3add35ee 100644
--- a/src/components/InputUrl.tsx
+++ b/src/components/InputUrl.tsx
@@ -3,55 +3,31 @@ import InputString from "./InputString";
import SmallError from "./SmallError";
import { Trans, type WithTranslation, withTranslation } from "react-i18next";
import { type TFunction } from "i18next";
+import { ErrorType, validate } from "../libs/urlopen";
-function validate(url: string, t: TFunction): JSX.Element | undefined {
- if (url === "") {
- return;
- }
-
- let error;
- const getProtocol = (url: string) => {
- try {
- const urlObj = new URL(url);
- return urlObj.protocol;
- }
- catch (_err) {
- return undefined;
- }
- };
- const protocol = getProtocol(url);
- const isSsl = window.location.protocol === "https:";
-
- if (!protocol) {
- if (isSsl) {
- error = (
+function errorTypeToJsx(errorType: ErrorType | undefined, t: TFunction): JSX.Element | undefined {
+ switch (errorType) {
+ case ErrorType.EmptyHttpsProtocol:
+ return (
Must provide protocol: https://
);
- } else {
- error = (
+ case ErrorType.EmptyHttpOrHttpsProtocol:
+ return (
Must provide protocol: http:// or https://
);
- }
+ case ErrorType.CorsError:
+ return (
+
+ CORS policy won't allow fetching resources served over http from https, use a https:// domain
+
+ );
+ default:
+ return undefined;
}
- else if (
- protocol &&
- protocol === "http:" &&
- window.location.protocol === "https:"
- ) {
- error = (
-
-
- CORS policy won't allow fetching resources served over http from https, use a https:// domain
-
-
- );
- }
-
- return error;
}
export type FieldUrlProps = {
@@ -71,7 +47,7 @@ export type FieldUrlProps = {
type InputUrlInternalProps = FieldUrlProps & WithTranslation;
type InputUrlState = {
- error?: React.ReactNode
+ error?: ErrorType
};
class InputUrlInternal extends React.Component {
@@ -82,20 +58,20 @@ class InputUrlInternal extends React.Component {
this.setState({
- error: validate(url, this.props.t),
+ error: validate(url),
});
if (this.props.onInput) this.props.onInput(url);
};
onChange = (url: string) => {
this.setState({
- error: validate(url, this.props.t),
+ error: validate(url),
});
this.props.onChange(url);
};
@@ -109,7 +85,7 @@ class InputUrlInternal extends React.Component
- {this.state.error}
+ {errorTypeToJsx(this.state.error, this.props.t)}
);
}
diff --git a/src/libs/urlopen.test.ts b/src/libs/urlopen.test.ts
new file mode 100644
index 00000000..3dc4c5db
--- /dev/null
+++ b/src/libs/urlopen.test.ts
@@ -0,0 +1,153 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { validate, ErrorType } from "./urlopen";
+
+// Mock window.location if not in browser environment
+const mockLocation = {
+ protocol: "http:",
+ hostname: "localhost",
+};
+
+Object.defineProperty(global, "window", {
+ value: {
+ location: mockLocation,
+ },
+ writable: true,
+});
+
+describe("validate", () => {
+ let originalProtocol: string;
+
+ beforeEach(() => {
+ // Save original protocol
+ originalProtocol = window.location.protocol;
+ });
+
+ afterEach(() => {
+ // Restore original protocol
+ Object.defineProperty(window.location, "protocol", {
+ writable: true,
+ value: originalProtocol,
+ });
+ });
+
+ describe("when URL is empty", () => {
+ it("should return ErrorType.None", () => {
+ expect(validate("")).toBe(ErrorType.None);
+ });
+ });
+
+ describe("when window.location.protocol is https:", () => {
+ beforeEach(() => {
+ Object.defineProperty(window.location, "protocol", {
+ writable: true,
+ value: "https:",
+ });
+ });
+
+ it("should return EmptyHttpsProtocol when URL has no protocol", () => {
+ expect(validate("example.com")).toBe(ErrorType.EmptyHttpsProtocol);
+ expect(validate("www.example.com/path")).toBe(ErrorType.EmptyHttpsProtocol);
+ });
+
+ it("should return None for valid https URLs", () => {
+ expect(validate("https://example.com")).toBe(ErrorType.None);
+ expect(validate("https://www.example.com/path")).toBe(ErrorType.None);
+ });
+
+ it("should return CorsError for http URLs pointing to non-local hosts", () => {
+ expect(validate("http://example.com")).toBe(ErrorType.CorsError);
+ expect(validate("http://api.example.com/endpoint")).toBe(ErrorType.CorsError);
+ });
+
+ it("should return None for http URLs pointing to localhost", () => {
+ expect(validate("http://localhost")).toBe(ErrorType.None);
+ expect(validate("http://localhost:3000")).toBe(ErrorType.None);
+ expect(validate("http://127.0.0.1")).toBe(ErrorType.None);
+ expect(validate("http://127.0.0.1:8080")).toBe(ErrorType.None);
+ expect(validate("http://127.255.255.255")).toBe(ErrorType.None);
+ });
+
+ it("should return None for http URLs pointing to IPv6 localhost", () => {
+ expect(validate("http://[::1]")).toBe(ErrorType.None);
+ expect(validate("http://[::1]:3000")).toBe(ErrorType.None);
+ });
+
+ it("should return None for other protocols", () => {
+ expect(validate("ftp://example.com")).toBe(ErrorType.None);
+ expect(validate("ws://example.com")).toBe(ErrorType.None);
+ expect(validate("wss://example.com")).toBe(ErrorType.None);
+ });
+ });
+
+ describe("when window.location.protocol is http:", () => {
+ beforeEach(() => {
+ Object.defineProperty(window.location, "protocol", {
+ writable: true,
+ value: "http:",
+ });
+ });
+
+ it("should return EmptyHttpOrHttpsProtocol when URL has no protocol", () => {
+ expect(validate("example.com")).toBe(ErrorType.EmptyHttpOrHttpsProtocol);
+ expect(validate("www.example.com/path")).toBe(ErrorType.EmptyHttpOrHttpsProtocol);
+ });
+
+ it("should return None for valid http URLs", () => {
+ expect(validate("http://example.com")).toBe(ErrorType.None);
+ expect(validate("http://www.example.com/path")).toBe(ErrorType.None);
+ });
+
+ it("should return None for valid https URLs", () => {
+ expect(validate("https://example.com")).toBe(ErrorType.None);
+ expect(validate("https://www.example.com/path")).toBe(ErrorType.None);
+ });
+
+ it("should return None for localhost URLs", () => {
+ expect(validate("http://localhost")).toBe(ErrorType.None);
+ expect(validate("http://127.0.0.1")).toBe(ErrorType.None);
+ });
+ });
+
+ describe("edge cases", () => {
+ it("should handle URLs with ports", () => {
+ Object.defineProperty(window.location, "protocol", {
+ writable: true,
+ value: "https:",
+ });
+
+ expect(validate("https://example.com:8443")).toBe(ErrorType.None);
+ expect(validate("http://example.com:8080")).toBe(ErrorType.CorsError);
+ expect(validate("http://localhost:3000")).toBe(ErrorType.None);
+ });
+
+ it("should handle URLs with paths and query strings", () => {
+ Object.defineProperty(window.location, "protocol", {
+ writable: true,
+ value: "https:",
+ });
+
+ expect(validate("https://example.com/path?query=value")).toBe(ErrorType.None);
+ expect(validate("http://example.com/path?query=value")).toBe(ErrorType.CorsError);
+ });
+
+ it("should handle malformed URLs that cannot be parsed", () => {
+ Object.defineProperty(window.location, "protocol", {
+ writable: true,
+ value: "https:",
+ });
+
+ expect(validate("not a url at all")).toBe(ErrorType.EmptyHttpsProtocol);
+ expect(validate("://")).toBe(ErrorType.EmptyHttpsProtocol);
+ });
+
+ it("should handle localhost variations case-insensitively", () => {
+ Object.defineProperty(window.location, "protocol", {
+ writable: true,
+ value: "https:",
+ });
+
+ expect(validate("http://LOCALHOST")).toBe(ErrorType.None);
+ expect(validate("http://LocalHost:3000")).toBe(ErrorType.None);
+ });
+ });
+});
diff --git a/src/libs/urlopen.ts b/src/libs/urlopen.ts
index a793f081..86c27a8b 100644
--- a/src/libs/urlopen.ts
+++ b/src/libs/urlopen.ts
@@ -25,3 +25,45 @@ export async function loadStyleUrl(styleUrl: string): Promise