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