diff --git a/bun.lockb b/bun.lockb index caaa0da..b9b8868 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/i18n/en.json b/i18n/en.json index b91b234..a9fdd73 100755 --- a/i18n/en.json +++ b/i18n/en.json @@ -13,8 +13,9 @@ }, "search-help-text": "Search {engine}" }, - "404": { - "title": "Page Not Found" + "notfound": { + "title": "page not found", + "desc": "Please check if there is a typo in the URL.
If SparkHome brought you to this page,
please contact us." }, "about": { "title": "SparkHome" diff --git a/lib/url/validLink.ts b/lib/url/validLink.ts index 04026eb..5489f6e 100644 --- a/lib/url/validLink.ts +++ b/lib/url/validLink.ts @@ -1,22 +1,26 @@ -import punycode from "punycode/"; +import punycode from "punycode"; import { tldList } from "./tldList"; export default function validLink(link: string) { - let finalURL = ''; + let finalURL; try { const url = new URL(link); - finalURL = url.origin; + finalURL = url; return true; } catch (error) { // if the URL is invalid, try to add the protocol try { const urlWithHTTP = new URL("http://" + link); - finalURL = urlWithHTTP.origin; + finalURL = urlWithHTTP; } catch (error) { return false; } } - if (validTLD(finalURL)) { + if ( + validTLD(finalURL.host) || + isValidIPv6(finalURL.host.slice(1, finalURL.host.length - 1)) || + isValidIPv4(finalURL.host) + ) { return true; } else { return false; @@ -31,3 +35,54 @@ export function validTLD(domain: string): boolean { return false; } } + +export function isValidIPv6(ip: string): boolean { + const length = ip.length; + let groups = 1; + let groupDigits = 0; + let doubleColonCount = 0; + for (let i = 0; i < length; i++) { + const char = ip[i]; + if ("0" <= char && char <= "9") { + groupDigits++; + } else if ("a" <= char && char <= "f") { + groupDigits++; + } else if ("A" <= char && char <= "F") { + groupDigits++; + } else if (char === ":" && i + 1 < length && ip[i + 1] !== ":") { + groups++; + groupDigits = 0; + } else if (char === ":" && i + 1 < length && ip[i + 1] === ":") { + doubleColonCount++; + i++; + groupDigits = 0; + } else { + return false; + } + if (groups > 8) { + return false; + } else if (groupDigits > 4) { + return false; + } else if (doubleColonCount > 1) { + return false; + } + } + if (doubleColonCount === 0 && groups !== 8) { + return false; + } + return true; +} + +export function isValidIPv4(ip: string): boolean { + const parts = ip.split("."); + if (parts.length !== 4) { + return false; + } + for (const part of parts) { + const num = Number(part); + if (isNaN(num) || num < 0 || num > 255 || !part.match(/^\d+$/)) { + return false; + } + } + return true; +} diff --git a/package.json b/package.json index cd8479c..6061e1a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sparkhome", "private": false, - "version": "5.2.1", + "version": "5.2.2", "type": "module", "scripts": { "dev": "bun server.ts", @@ -21,6 +21,7 @@ "i18next": "^23.11.5", "i18next-browser-languagedetector": "^8.0.0", "i18next-icu": "^2.3.0", + "jest": "^29.7.0", "jotai": "^2.8.3", "node-nlp": "^4.27.0", "react": "^18.3.1", diff --git a/pages/[...].tsx b/pages/[...].tsx new file mode 100644 index 0000000..13233a7 --- /dev/null +++ b/pages/[...].tsx @@ -0,0 +1,17 @@ +import { useTranslation } from "react-i18next"; + +export default function NotFound() { + const { t } = useTranslation(); + return ( +
+
+

404

+
+
+
{t("notfound.title")}
+
+
+
+
+ ); +} diff --git a/test/validLink.test.ts b/test/validLink.test.ts new file mode 100644 index 0000000..49ee01f --- /dev/null +++ b/test/validLink.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "@jest/globals"; +import validLink, { validTLD } from "../lib/url/validLink"; + +describe("Check if a string is an accessible domain/URL/IP", () => { + test("Plain, full URL", () => { + // Plain form + expect(validLink("http://example.com")).toBe(true); + // With https and path + expect(validLink("https://jestjs.io/docs/getting-started/")).toBe(true); + // With anchor + expect(validLink("https://difftastic.wilfred.me.uk/zh-CN/git.html#git-difftool")).toBe(true); + // With params + expect(validLink("https://www.bilibili.com/list/ml2252204359?oid=990610203&bvid=BV1sx4y1g7Hh")).toBe(true); + }); + test("Punycode URL", () => { + expect(validLink("https://原神大学.com/")).toBe(true); + expect(validLink("中国原神大学.com")).toBe(true); + }); + test("Invalid TLD with protocol", () => { + expect(validLink("https://www.example.notexist")).toBe(true); + }); + test("Invalid TLD with no protocol", () => { + expect(validLink("www.example.notexist")).toBe(false); + }); + test("IPv4 without protocol", () => { + expect(validLink("127.0.0.1")).toBe(true); + }); + test("IPv6 without protocol", () => { + expect(validLink("[::]")).toBe(true); + }); +}); + +// Reference: https://www.iana.org/domains/root/db +describe("Check if the given TLD exist and assigned.", () => { + test("Valid normal TLD", () => { + expect(validTLD("com")).toBe(true); + expect(validTLD("top")).toBe(true); + expect(validTLD("net")).toBe(true); + expect(validTLD("org")).toBe(true); + }); + test("Valid new TLDs", () => { + // they really exist! + expect(validTLD("foo")).toBe(true); + expect(validTLD("bar")).toBe(true); + }); + test("Exist but not assigned TLD", () => { + expect(validTLD("active")).toBe(false); + expect(validTLD("off")).toBe(false); + }); + test("with dot", () => { + expect(validTLD(".com")).toBe(true); + expect(validTLD(".us")).toBe(true); + expect(validTLD(".cn")).toBe(true); + expect(validTLD(".io")).toBe(true); + }); + test("Punycode TLDs", () => { + expect(validTLD(".中国")).toBe(true); + expect(validTLD(".РФ")).toBe(true); + expect(validTLD(".कॉम")).toBe(true); + expect(validTLD("ایران")).toBe(true); + expect(validTLD("இலங்கை")).toBe(true); + expect(validTLD("გე")).toBe(true); + expect(validTLD("ポイント")).toBe(true); + }); + test("Punycode TLDs but not assigned", () => { + expect(validTLD("テスト")).toBe(false); + expect(validTLD("परीक्षा")).toBe(false); + expect(validTLD("测试")).toBe(false); + }); +});