From cfff79f237396730ca4948e0abd0fef8424ecb38 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:48:00 +0900 Subject: [PATCH 1/3] rebase --- src/app/service/service_worker/script.ts | 10 +- src/locales/ach-UG/translation.json | 6 +- src/locales/de-DE/translation.json | 2 + src/locales/en-US/translation.json | 6 +- src/locales/ja-JP/translation.json | 2 + src/locales/ru-RU/translation.json | 2 + src/locales/vi-VN/translation.json | 2 + src/locales/zh-CN/translation.json | 2 + src/locales/zh-TW/translation.json | 4 +- src/pages/install/App.tsx | 621 ++++++++++++++--------- src/pkg/utils/url-utils.test.ts | 476 +++++++++++++++++ src/pkg/utils/url-utils.ts | 214 ++++++++ src/pkg/utils/utils.ts | 34 -- 13 files changed, 1090 insertions(+), 291 deletions(-) create mode 100644 src/pkg/utils/url-utils.test.ts create mode 100644 src/pkg/utils/url-utils.ts diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 227be28d1..2d610bcfd 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -266,7 +266,15 @@ export class ScriptService { action: { type: "redirect" as chrome.declarativeNetRequest.RuleActionType, redirect: { - regexSubstitution: `${installPageURL}?url=\\1`, + /** + * 核心设计: + * 使用 `<,\1,>` 作为特征锚点注入到重定向 URL 中。 + * 1. 引导格式化:利用 \1 提取正则捕获组内容。 + * 2. 编码探测:通过包裹特殊的定界符(尖括号和逗号),在目标页面解析时, + * 可以通过检测这些字符是否被转义(如变为 %3C, %2C)来精准判定 + * 浏览器底层触发的是哪种 URL 编码策略(Raw / encodeURI / encodeURIComponent)。 + */ + regexSubstitution: `${installPageURL}?url=<,\\1,>`, }, }, condition: condition, diff --git a/src/locales/ach-UG/translation.json b/src/locales/ach-UG/translation.json index cf254e349..7b984f867 100644 --- a/src/locales/ach-UG/translation.json +++ b/src/locales/ach-UG/translation.json @@ -325,8 +325,10 @@ "header_other_update": "crwdns12784:0crwdne12784:0" }, "downloading_status_text": "Downloading. Received {{bytes}}.", - "install_page_loading": "Installation page loading", - "invalid_page": "Invalid page", + "install_page_please_wait": "Please wait", + "install_page_loading": "Loading Installation Page", + "install_page_load_failed": "Failed to Load Installation Page", + "invalid_page": "Invalid Page", "background_script_tag": "crwdns8460:0crwdne8460:0", "scheduled_script_tag": "crwdns8462:0crwdne8462:0", "background_script": "crwdns8464:0crwdne8464:0", diff --git a/src/locales/de-DE/translation.json b/src/locales/de-DE/translation.json index 6ea643df8..dc389e7ea 100644 --- a/src/locales/de-DE/translation.json +++ b/src/locales/de-DE/translation.json @@ -334,7 +334,9 @@ "header_other_update": "Andere verfügbare Updates" }, "downloading_status_text": "Wird heruntergeladen. {{bytes}} empfangen.", + "install_page_please_wait": "Bitte warten Sie", "install_page_loading": "Installationsseite wird geladen", + "install_page_load_failed": "Installationsseite konnte nicht geladen werden", "invalid_page": "Ungültige Seite", "background_script_tag": "Dies ist ein Hintergrundskript", "scheduled_script_tag": "Dies ist ein geplantes Skript", diff --git a/src/locales/en-US/translation.json b/src/locales/en-US/translation.json index 1091314dd..006a3af2d 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -334,8 +334,10 @@ "header_other_update": "Other available updates" }, "downloading_status_text": "Downloading. Received {{bytes}}.", - "install_page_loading": "Installation page loading", - "invalid_page": "Invalid page", + "install_page_please_wait": "Please wait", + "install_page_loading": "Loading Installation Page", + "install_page_load_failed": "Failed to Load Installation Page", + "invalid_page": "Invalid Page", "background_script_tag": "This is a Background Script", "scheduled_script_tag": "This is a Scheduled Script", "background_script": "Background Script", diff --git a/src/locales/ja-JP/translation.json b/src/locales/ja-JP/translation.json index 346ac1524..68a3db516 100644 --- a/src/locales/ja-JP/translation.json +++ b/src/locales/ja-JP/translation.json @@ -334,7 +334,9 @@ "header_other_update": "その他の利用可能な更新" }, "downloading_status_text": "ダウンロード中。{{bytes}} を受信しました。", + "install_page_please_wait": "しばらくお待ちください", "install_page_loading": "インストールページを読み込み中", + "install_page_load_failed": "インストールページの読み込みに失敗しました", "invalid_page": "無効なページ", "background_script_tag": "これはバックグラウンドスクリプトです", "scheduled_script_tag": "これはスケジュールスクリプトです", diff --git a/src/locales/ru-RU/translation.json b/src/locales/ru-RU/translation.json index cc80fe49e..f3da00dc2 100644 --- a/src/locales/ru-RU/translation.json +++ b/src/locales/ru-RU/translation.json @@ -334,7 +334,9 @@ "header_other_update": "Другие доступные обновления" }, "downloading_status_text": "Загрузка. Получено {{bytes}}.", + "install_page_please_wait": "Пожалуйста, подождите", "install_page_loading": "Загрузка страницы установки", + "install_page_load_failed": "Не удалось загрузить страницу установки", "invalid_page": "Недействительная страница", "background_script_tag": "Это фоновый скрипт", "scheduled_script_tag": "Это запланированный скрипт", diff --git a/src/locales/vi-VN/translation.json b/src/locales/vi-VN/translation.json index f16febc99..ed482f3ed 100644 --- a/src/locales/vi-VN/translation.json +++ b/src/locales/vi-VN/translation.json @@ -334,7 +334,9 @@ "header_other_update": "Các bản cập nhật khác" }, "downloading_status_text": "Đang tải xuống. Đã nhận {{bytes}}.", + "install_page_please_wait": "Vui lòng chờ", "install_page_loading": "Đang tải trang cài đặt", + "install_page_load_failed": "Tải trang cài đặt thất bại", "invalid_page": "Trang không hợp lệ", "background_script_tag": "Đây là một script nền", "scheduled_script_tag": "Đây là một script hẹn giờ", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 508c9ea3d..80be7c663 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -334,7 +334,9 @@ "header_other_update": "其他可用更新" }, "downloading_status_text": "正在下载。已接收 {{bytes}}。", + "install_page_please_wait": "请稍等", "install_page_loading": "安装页面加载中", + "install_page_load_failed": "安装页面加载失败", "invalid_page": "无效页面", "background_script_tag": "这是一个后台脚本", "scheduled_script_tag": "这是一个定时脚本", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index abb5c289d..c384e2ab4 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -334,7 +334,9 @@ "header_other_update": "其他可用更新" }, "downloading_status_text": "正在下載。已接收 {{bytes}}。", - "install_page_loading": "安裝頁載入中", + "install_page_please_wait": "請稍等", + "install_page_loading": "安裝頁面載入中", + "install_page_load_failed": "安裝頁面載入失敗", "invalid_page": "無效頁面", "background_script_tag": "這是一個背景腳本", "scheduled_script_tag": "這是一個排程腳本", diff --git a/src/pages/install/App.tsx b/src/pages/install/App.tsx index d27481a9c..8b3e661f3 100644 --- a/src/pages/install/App.tsx +++ b/src/pages/install/App.tsx @@ -31,9 +31,10 @@ import { intervalExecution, timeoutExecution } from "@App/pkg/utils/timer"; import { useSearchParams } from "react-router-dom"; import { CACHE_KEY_SCRIPT_INFO } from "@App/app/cache_key"; import { cacheInstance } from "@App/app/cache"; -import { formatBytes, prettyUrl } from "@App/pkg/utils/utils"; +import { formatBytes } from "@App/pkg/utils/utils"; import { ScriptIcons } from "../options/routes/utils"; import { bytesDecode, detectEncoding } from "@App/pkg/utils/encoding"; +import { toEncodedURL, prettyUrl } from "@App/pkg/utils/url-utils"; const backgroundPromptShownKey = "background_prompt_shown"; @@ -48,14 +49,56 @@ interface PermissionItem { type Permission = PermissionItem[]; -const closeWindow = (doBackwards: boolean) => { - if (doBackwards) { +const closeWindow = (shouldGoBack: boolean) => { + if (shouldGoBack) { history.go(-1); } else { window.close(); } }; +const getCandidateUrls = (targetUrlHref: string) => { + const encodedUrl = toEncodedURL(targetUrlHref); + const inputU = new URL(encodedUrl); + const extraCandidateUrls = new Set(); + extraCandidateUrls.add(inputU.href); + + const hostname = inputU.hostname; + // 兼容 .greasyfork.org, cn-greasyfork.org + const hostText = `.${hostname}`.replace(/\W/g, "."); + const isGreasyFork = hostText.endsWith(".greasyfork.org"); + const isSleazyFork = hostText.endsWith(".sleazyfork.org"); + + if (isGreasyFork || isSleazyFork) { + // example: + // CASE 1 + // raw 'https://update.greasyfork.org/scripts/550295/100%解锁CSDN文库vip文章阅读限制.user.js' + // encoded 'https://update.greasyfork.org/scripts/550295/100%25%E8%A7%A3%E9%94%81CSDN%E6%96%87%E5%BA%93vip%E6%96%87%E7%AB%A0%E9%98%85%E8%AF%BB%E9%99%90%E5%88%B6.user.js' + // correct 'https://update.greasyfork.org/scripts/550295/100%25%E8%A7%A3%E9%94%81CSDN%E6%96%87%E5%BA%93vip%E6%96%87%E7%AB%A0%E9%98%85%E8%AF%BB%E9%99%90%E5%88%B6.user.js' + // CASE 2 + // raw 'https://update.greasyfork.org/scripts/519037/Nexus No Wait ++.user.js' + // encoded 'https://update.greasyfork.org/scripts/519037/Nexus%20No%20Wait%20++.user.js' + // correct 'https://update.greasyfork.org/scripts/519037/Nexus%20No%20Wait%20%2B%2B.user.js' + try { + const encodedPathname = inputU.pathname; + const lastSlashIndex = encodedPathname.lastIndexOf("/"); + const basePath = encodedPathname.substring(0, lastSlashIndex); + const fileName = encodedPathname.substring(lastSlashIndex + 1); + const reEncodedFileName = encodeURIComponent(decodeURI(fileName)); + if (reEncodedFileName !== fileName) { + const reEncodedPathName = `${basePath}/${reEncodedFileName}`; + const reEncodedUrl = `${inputU.origin}${reEncodedPathName}${inputU.search}${inputU.hash}`; + extraCandidateUrls.add(reEncodedUrl); + } + } catch (e) { + // can skip if it cannot be converted using decodeURI + console.warn(e); // just a warning for debug purpose. + } + } + + return [...extraCandidateUrls]; +}; + const fetchScriptBody = async (url: string, { onProgress }: { [key: string]: any }) => { let origin; try { @@ -72,7 +115,7 @@ const fetchScriptBody = async (url: string, { onProgress }: { [key: string]: any "Accept-Encoding": "br;q=1.0, gzip;q=0.8, *;q=0.1", Origin: origin, }, - referrer: origin + "/", + referrer: `${origin}/`, }); if (!response.ok) { @@ -133,43 +176,43 @@ const fetchScriptBody = async (url: string, { onProgress }: { [key: string]: any return { code, metadata }; }; -const cleanupStaleInstallInfo = (uuid: string) => { +const cleanupStaleInstallInfo = (scriptUuid: string) => { // 页面打开时不清除当前uuid,每30秒更新一次记录 - const f = () => { + const updateKeepAlive = () => { cacheInstance.tx(`scriptInfoKeeps`, (val: Record | undefined, tx) => { val = val || {}; - val[uuid] = Date.now(); + val[scriptUuid] = Date.now(); tx.set(val); }); }; - f(); - setInterval(f, 30_000); + updateKeepAlive(); + setInterval(updateKeepAlive, 30_000); // 页面打开后清除旧记录 const delay = Math.floor(5000 * Math.random()) + 10000; // 使用随机时间避免浏览器重启时大量Tabs同时执行清除 timeoutExecution( - `${cIdKey}cleanupStaleInstallInfo`, + `${componentInstanceId}cleanupStaleInstallInfo`, () => { cacheInstance .tx(`scriptInfoKeeps`, (val: Record | undefined, tx) => { const now = Date.now(); - const keeps = new Set(); - const out: Record = {}; + const activeKeepKeys = new Set(); + const updatedRegistry: Record = {}; for (const [k, ts] of Object.entries(val ?? {})) { if (ts > 0 && now - ts < 60_000) { - keeps.add(`${CACHE_KEY_SCRIPT_INFO}${k}`); - out[k] = ts; + activeKeepKeys.add(`${CACHE_KEY_SCRIPT_INFO}${k}`); + updatedRegistry[k] = ts; } } - tx.set(out); - return keeps; + tx.set(updatedRegistry); + return activeKeepKeys; }) .then(async (keeps) => { - const list = await cacheInstance.list(); - const filtered = list.filter((key) => key.startsWith(CACHE_KEY_SCRIPT_INFO) && !keeps.has(key)); - if (filtered.length) { + const allCacheKeys = await cacheInstance.list(); + const keysToPurge = allCacheKeys.filter((key) => key.startsWith(CACHE_KEY_SCRIPT_INFO) && !keeps.has(key)); + if (keysToPurge.length) { // 清理缓存 - cacheInstance.dels(filtered); + cacheInstance.dels(keysToPurge); } }); }, @@ -177,34 +220,34 @@ const cleanupStaleInstallInfo = (uuid: string) => { ); }; -const cIdKey = `(cid_${Math.random()})`; +const componentInstanceId = `(cid_${Math.random()})`; function App() { - const [enable, setEnable] = useState(false); - const [btnText, setBtnText] = useState(""); - const [scriptCode, setScriptCode] = useState(""); - const [scriptInfo, setScriptInfo] = useState(); - const [upsertScript, setUpsertScript] = useState(undefined); - const [diffCode, setDiffCode] = useState(); - const [oldScriptVersion, setOldScriptVersion] = useState(null); - const [isUpdate, setIsUpdate] = useState(false); + const [isScriptEnabled, setIsScriptEnabled] = useState(false); + const [installButtonText, setInstallButtonText] = useState(""); + const [currentScriptCode, setCurrentScriptCode] = useState(""); + const [scriptInstallConfig, setScriptInstallConfig] = useState(); + const [pendingScript, setPendingScript] = useState(undefined); + const [diffBaseCode, setDiffBaseCode] = useState(); + const [installedVersion, setInstalledVersion] = useState(null); + const [isUpdateMode, setIsUpdateMode] = useState(false); const [localFileHandle, setLocalFileHandle] = useState(null); const [showBackgroundPrompt, setShowBackgroundPrompt] = useState(false); const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); - const [loaded, setLoaded] = useState(false); - const [doBackwards, setDoBackwards] = useState(false); + const [isPageLoaded, setIsPageLoaded] = useState(false); + const [shouldNavigateBack, setShouldNavigateBack] = useState(false); const installOrUpdateScript = async (newScript: Script, code: string) => { if (newScript.ignoreVersion) newScript.ignoreVersion = ""; await scriptClient.install({ script: newScript, code }); const metadata = newScript.metadata; - setScriptInfo((prev) => (prev ? { ...prev, code, metadata } : prev)); + setScriptInstallConfig((prev) => (prev ? { ...prev, code, metadata } : prev)); const scriptVersion = metadata.version?.[0]; - const oldScriptVersion = typeof scriptVersion === "string" ? scriptVersion : "N/A"; - setOldScriptVersion(oldScriptVersion); - setUpsertScript(newScript); - setDiffCode(code); + const versionStr = typeof scriptVersion === "string" ? scriptVersion : "N/A"; + setInstalledVersion(versionStr); + setPendingScript(newScript); + setDiffBaseCode(code); }; const getUpdatedNewScript = async (uuid: string, code: string) => { @@ -220,11 +263,11 @@ function App() { return script; }; - const initAsync = async () => { + const initializeInstallation = async () => { try { const uuid = searchParams.get("uuid"); const fid = searchParams.get("file"); - let info: ScriptInfo | undefined; + let installInfo: ScriptInfo | undefined; let isKnownUpdate: boolean = false; // 如果没有 uuid 和 file,跳过初始化逻辑 @@ -233,18 +276,18 @@ function App() { } if (window.history.length > 1) { - setDoBackwards(true); + setShouldNavigateBack(true); } - setLoaded(true); + setIsPageLoaded(true); let paramOptions = {}; if (uuid) { - const cachedInfo = await scriptClient.getInstallInfo(uuid); + const cachedData = await scriptClient.getInstallInfo(uuid); cleanupStaleInstallInfo(uuid); - if (cachedInfo?.[0]) isKnownUpdate = true; - info = cachedInfo?.[1] || undefined; - paramOptions = cachedInfo?.[2] || {}; - if (!info) { + if (cachedData?.[0]) isKnownUpdate = true; + installInfo = cachedData?.[1] || undefined; + paramOptions = cachedData?.[2] || {}; + if (!installInfo) { throw new Error("fetch script info failed"); } } else { @@ -269,136 +312,139 @@ function App() { // 刷新 timestamp, 使 10s~15s 后不会被立即清掉 // 每五分钟刷新一次db记录的timestamp,使开启中的安装页面的fileHandle不会被刷掉 - intervalExecution(`${cIdKey}liveFileHandle`, () => saveHandle(fid, fileHandle), 5 * 60 * 1000, true); + const key = `${componentInstanceId}liveFileHandle`; + intervalExecution(key, () => saveHandle(fid, fileHandle), 5 * 60 * 1000, true); const code = await file.text(); const metadata = parseMetadata(code); if (!metadata) { throw new Error("parse script info failed"); } - info = createScriptInfo(uuidv4(), code, `file:///*from-local*/${file.name}`, "user", metadata); + installInfo = createScriptInfo(uuidv4(), code, `file:///*from-local*/${file.name}`, "user", metadata); } - let prepare: + let preparationResult: | { script: Script; oldScript?: Script; oldScriptCode?: string } | { subscribe: Subscribe; oldSubscribe?: Subscribe }; - let action: Script | Subscribe; - - const { code, url } = info; - let oldVersion: string | undefined = undefined; - let diffCode: string | undefined = undefined; - if (info.userSubscribe) { - prepare = await prepareSubscribeByCode(code, url); - action = prepare.subscribe; - if (prepare.oldSubscribe) { - const oldSubscribeVersion = prepare.oldSubscribe.metadata.version?.[0]; - oldVersion = typeof oldSubscribeVersion === "string" ? oldSubscribeVersion : "N/A"; + let finalActionObject: Script | Subscribe; + + const { code, url } = installInfo; + let oldVersionStr: string | undefined = undefined; + let baseDiffCode: string | undefined = undefined; + if (installInfo.userSubscribe) { + preparationResult = await prepareSubscribeByCode(code, url); + finalActionObject = preparationResult.subscribe; + if (preparationResult.oldSubscribe) { + const oldSubscribeVersion = preparationResult.oldSubscribe.metadata.version?.[0]; + oldVersionStr = typeof oldSubscribeVersion === "string" ? oldSubscribeVersion : "N/A"; } - diffCode = prepare.oldSubscribe?.code; + baseDiffCode = preparationResult.oldSubscribe?.code; } else { - const knownUUID = isKnownUpdate ? info.uuid : undefined; - prepare = await prepareScriptByCode(code, url, knownUUID, false, undefined, paramOptions); - action = prepare.script; - if (prepare.oldScript) { - const oldScriptVersion = prepare.oldScript.metadata.version?.[0]; - oldVersion = typeof oldScriptVersion === "string" ? oldScriptVersion : "N/A"; + const knownUUID = isKnownUpdate ? installInfo.uuid : undefined; + preparationResult = await prepareScriptByCode(code, url, knownUUID, false, undefined, paramOptions); + finalActionObject = preparationResult.script; + if (preparationResult.oldScript) { + const oldScriptVersion = preparationResult.oldScript.metadata.version?.[0]; + oldVersionStr = typeof oldScriptVersion === "string" ? oldScriptVersion : "N/A"; } - diffCode = prepare.oldScriptCode; + baseDiffCode = preparationResult.oldScriptCode; } - setScriptCode(code); - setDiffCode(diffCode); - setOldScriptVersion(typeof oldVersion === "string" ? oldVersion : null); - setIsUpdate(typeof oldVersion === "string"); - setScriptInfo(info); - setUpsertScript(action); + setCurrentScriptCode(code); + setDiffBaseCode(baseDiffCode); + setInstalledVersion(typeof oldVersionStr === "string" ? oldVersionStr : null); + setIsUpdateMode(typeof oldVersionStr === "string"); + setScriptInstallConfig(installInfo); + setPendingScript(finalActionObject); // 检查是否需要显示后台运行提示 - if (!info.userSubscribe) { - setShowBackgroundPrompt(await checkBackgroundPrompt(action as Script)); + if (!installInfo.userSubscribe) { + setShowBackgroundPrompt(await checkBackgroundPrompt(finalActionObject as Script)); } } catch (e: any) { - Message.error(t("script_info_load_failed") + " " + e.message); + Message.error(`${t("script_info_load_failed")} ${e?.message ?? e}`); } finally { // fileHandle 保留处理方式(暂定): // fileHandle 会保留一段足够时间,避免用户重新刷画面,重启浏览器等操作后,安装页变得空白一片。 // 处理会在所有Tab都载入后(不包含睡眠Tab)进行,因此延迟 10s~15s 让处理有足够时间。 // 安装页面关掉后15分钟为不保留状态,会在安装画面再次打开时(其他脚本安装),进行清除。 - const delay = Math.floor(5000 * Math.random()) + 10000; // 使用乱数时间避免浏览器重启时大量Tabs同时执行DB清除 - timeoutExecution(`${cIdKey}cleanupFileHandle`, cleanupOldHandles, delay); + const randomDelay = Math.floor(5000 * Math.random()) + 10000; // 使用乱数时间避免浏览器重启时大量Tabs同时执行DB清除 + timeoutExecution(`${componentInstanceId}cleanupFileHandle`, cleanupOldHandles, randomDelay); } }; + // 有 file 或 uuid 时加载安装画面 useEffect(() => { - !loaded && initAsync(); - }, [searchParams, loaded]); + !isPageLoaded && initializeInstallation(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams.get("uuid"), searchParams.get("file"), isPageLoaded]); - const [watchFile, setWatchFile] = useState(false); - const metadataLive = useMemo(() => (scriptInfo?.metadata || {}) as SCMetadata, [scriptInfo]); + const [isFileWatchingEnabled, setIsFileWatchingEnabled] = useState(false); + const liveMetadata = useMemo(() => (scriptInstallConfig?.metadata || {}) as SCMetadata, [scriptInstallConfig]); - const permissions = useMemo(() => { + const scriptPermissions = useMemo(() => { const permissions: Permission = []; - if (!scriptInfo) return permissions; + if (!scriptInstallConfig) return permissions; - if (scriptInfo.userSubscribe) { + if (scriptInstallConfig.userSubscribe) { permissions.push({ label: t("subscribe_install_label"), color: "#ff0000", - value: metadataLive.scripturl!, + value: liveMetadata.scripturl!, }); } - if (metadataLive.match) { - permissions.push({ label: t("script_runs_in"), value: metadataLive.match }); + if (liveMetadata.match) { + permissions.push({ label: t("script_runs_in"), value: liveMetadata.match }); } - if (metadataLive.connect) { + if (liveMetadata.connect) { permissions.push({ label: t("script_has_full_access_to"), color: "#F9925A", - value: metadataLive.connect, + value: liveMetadata.connect, }); } - if (metadataLive.require) { - permissions.push({ label: t("script_requires"), value: metadataLive.require }); + if (liveMetadata.require) { + permissions.push({ label: t("script_requires"), value: liveMetadata.require }); } return permissions; - }, [scriptInfo, metadataLive, t]); + }, [scriptInstallConfig, liveMetadata, t]); - const descriptionParagraph = useMemo(() => { - const ret: JSX.Element[] = []; + const descriptionParagraphs = useMemo(() => { + const elements: JSX.Element[] = []; - if (!scriptInfo) return ret; + if (!scriptInstallConfig) return elements; - const isCookie = metadataLive.grant?.some((val) => val === "GM_cookie"); - if (isCookie) { - ret.push( + const hasCookieGrant = liveMetadata.grant?.some((val) => val === "GM_cookie"); + if (hasCookieGrant) { + elements.push( {t("cookie_warning")} ); } - if (metadataLive.crontab) { - ret.push({t("scheduled_script_description_title")}); - ret.push( + if (liveMetadata.crontab) { + elements.push({t("scheduled_script_description_title")}); + elements.push(
{t("scheduled_script_description_description_expr")} - {metadataLive.crontab[0]} + {liveMetadata.crontab[0]} {t("scheduled_script_description_description_next")} - {nextTimeDisplay(metadataLive.crontab[0])} + {nextTimeDisplay(liveMetadata.crontab[0])}
); - } else if (metadataLive.background) { - ret.push({t("background_script_description")}); + } else if (liveMetadata.background) { + elements.push({t("background_script_description")}); } - return ret; - }, [scriptInfo, metadataLive, t]); + return elements; + }, [scriptInstallConfig, liveMetadata, t]); - const antifeatures: { [key: string]: { color: string; title: string; description: string } } = { + const antifeatureRegistry: { [key: string]: { color: string; title: string; description: string } } = { "referral-link": { color: "purple", title: t("antifeature_referral_link_title"), @@ -433,22 +479,22 @@ function App() { // 更新按钮文案和页面标题 useEffect(() => { - if (scriptInfo?.userSubscribe) { - setBtnText(isUpdate ? t("update_subscribe")! : t("install_subscribe")); + if (scriptInstallConfig?.userSubscribe) { + setInstallButtonText(isUpdateMode ? t("update_subscribe")! : t("install_subscribe")); } else { - setBtnText(isUpdate ? t("update_script")! : t("install_script")); + setInstallButtonText(isUpdateMode ? t("update_script")! : t("install_script")); } - if (upsertScript) { - document.title = `${!isUpdate ? t("install_script") : t("update_script")} - ${i18nName(upsertScript!)} - ScriptCat`; + if (pendingScript) { + document.title = `${!isUpdateMode ? t("install_script") : t("update_script")} - ${i18nName(pendingScript!)} - ScriptCat`; } - }, [isUpdate, scriptInfo, upsertScript, t]); + }, [isUpdateMode, scriptInstallConfig, pendingScript, t]); // 设置脚本状态 useEffect(() => { - if (upsertScript) { - setEnable(upsertScript.status === SCRIPT_STATUS_ENABLE); + if (pendingScript) { + setIsScriptEnabled(pendingScript.status === SCRIPT_STATUS_ENABLE); } - }, [upsertScript]); + }, [pendingScript]); // 检查是否需要显示后台运行提示 const checkBackgroundPrompt = async (script: Script) => { @@ -469,8 +515,8 @@ function App() { return false; }; - const handleInstall = async (options: { closeAfterInstall?: boolean; noMoreUpdates?: boolean } = {}) => { - if (!upsertScript) { + const executeInstallation = async (options: { closeAfterInstall?: boolean; noMoreUpdates?: boolean } = {}) => { + if (!pendingScript) { Message.error(t("script_info_load_failed")!); return; } @@ -478,57 +524,57 @@ function App() { const { closeAfterInstall: shouldClose = true, noMoreUpdates: disableUpdates = false } = options; try { - if (scriptInfo?.userSubscribe) { - await subscribeClient.install(upsertScript as Subscribe); + if (scriptInstallConfig?.userSubscribe) { + await subscribeClient.install(pendingScript as Subscribe); Message.success(t("subscribe_success")!); - setBtnText(t("subscribe_success")!); + setInstallButtonText(t("subscribe_success")!); } else { // 如果选择不再检查更新,可以在这里设置脚本的更新配置 - if (disableUpdates && upsertScript) { + if (disableUpdates && pendingScript) { // 这里可以设置脚本禁用自动更新的逻辑 - (upsertScript as Script).checkUpdate = false; + (pendingScript as Script).checkUpdate = false; } // 故意只安装或执行,不改变显示内容 - await scriptClient.install({ script: upsertScript as Script, code: scriptCode }); - if (isUpdate) { + await scriptClient.install({ script: pendingScript as Script, code: currentScriptCode }); + if (isUpdateMode) { Message.success(t("install.update_success")!); - setBtnText(t("install.update_success")!); + setInstallButtonText(t("install.update_success")!); } else { // 如果选择不再检查更新,可以在这里设置脚本的更新配置 - if (disableUpdates && upsertScript) { + if (disableUpdates && pendingScript) { // 这里可以设置脚本禁用自动更新的逻辑 - (upsertScript as Script).checkUpdate = false; + (pendingScript as Script).checkUpdate = false; } - if ((upsertScript as Script).ignoreVersion) (upsertScript as Script).ignoreVersion = ""; + if ((pendingScript as Script).ignoreVersion) (pendingScript as Script).ignoreVersion = ""; // 故意只安装或执行,不改变显示内容 - await scriptClient.install({ script: upsertScript as Script, code: scriptCode }); - if (isUpdate) { + await scriptClient.install({ script: pendingScript as Script, code: currentScriptCode }); + if (isUpdateMode) { Message.success(t("install.update_success")!); - setBtnText(t("install.update_success")!); + setInstallButtonText(t("install.update_success")!); } else { Message.success(t("install_success")!); - setBtnText(t("install_success")!); + setInstallButtonText(t("install_success")!); } } } if (shouldClose) { setTimeout(() => { - closeWindow(doBackwards); + closeWindow(shouldNavigateBack); }, 500); } } catch (e) { - const errorMessage = scriptInfo?.userSubscribe ? t("subscribe_failed") : t("install_failed"); + const errorMessage = scriptInstallConfig?.userSubscribe ? t("subscribe_failed") : t("install_failed"); Message.error(`${errorMessage}: ${e}`); } }; - const handleClose = (options?: { noMoreUpdates: boolean }) => { + const handlePageClose = (options?: { noMoreUpdates: boolean }) => { const { noMoreUpdates = false } = options || {}; - if (noMoreUpdates && scriptInfo && !scriptInfo.userSubscribe) { - scriptClient.setCheckUpdateUrl(scriptInfo.uuid, false); + if (noMoreUpdates && scriptInstallConfig && !scriptInstallConfig.userSubscribe) { + scriptClient.setCheckUpdateUrl(scriptInstallConfig.uuid, false); } - closeWindow(doBackwards); + closeWindow(shouldNavigateBack); }; const { @@ -538,45 +584,45 @@ function App() { handleStatusChange, handleCloseBasic, handleCloseNoMoreUpdates, - setWatchFileClick, + toggleFileWatch, } = { - handleInstallBasic: () => handleInstall(), - handleInstallCloseAfterInstall: () => handleInstall({ closeAfterInstall: false }), - handleInstallNoMoreUpdates: () => handleInstall({ noMoreUpdates: true }), + handleInstallBasic: () => executeInstallation(), + handleInstallCloseAfterInstall: () => executeInstallation({ closeAfterInstall: false }), + handleInstallNoMoreUpdates: () => executeInstallation({ noMoreUpdates: true }), handleStatusChange: (checked: boolean) => { - setUpsertScript((script) => { + setPendingScript((script) => { if (!script) { return script; } script.status = checked ? SCRIPT_STATUS_ENABLE : SCRIPT_STATUS_DISABLE; - setEnable(checked); + setIsScriptEnabled(checked); return script; }); }, - handleCloseBasic: () => handleClose(), - handleCloseNoMoreUpdates: () => handleClose({ noMoreUpdates: true }), - setWatchFileClick: () => { - setWatchFile((prev) => !prev); + handleCloseBasic: () => handlePageClose(), + handleCloseNoMoreUpdates: () => handlePageClose({ noMoreUpdates: true }), + toggleFileWatch: () => { + setIsFileWatchingEnabled((prev) => !prev); }, }; const fileWatchMessageId = `id_${Math.random()}`; async function onWatchFileCodeChanged(this: FTInfo, code: string, hideInfo: boolean = false) { - if (this.uuid !== scriptInfo?.uuid) return; + if (this.uuid !== scriptInstallConfig?.uuid) return; if (this.fileName !== localFileHandle?.name) return; - setScriptCode(code); - const uuid = (upsertScript as Script)?.uuid; + setCurrentScriptCode(code); + const uuid = (pendingScript as Script)?.uuid; if (!uuid) { throw new Error("uuid is undefined"); } try { const newScript = await getUpdatedNewScript(uuid, code); await installOrUpdateScript(newScript, code); - } catch (e) { + } catch (e: any) { Message.error({ id: fileWatchMessageId, - content: t("install_failed") + ": " + e, + content: `${t("install_failed")}: ${e?.message ?? e}`, }); return; } @@ -593,21 +639,19 @@ function App() { async function onWatchFileError() { // e.g. NotFoundError - setWatchFile(false); + setIsFileWatchingEnabled(false); } - const memoWatchFile = useMemo(() => { - return `${watchFile}.${scriptInfo?.uuid}.${localFileHandle?.name}`; - }, [watchFile, scriptInfo, localFileHandle]); + const watchFileStateIdentifier = `${isFileWatchingEnabled}.${scriptInstallConfig?.uuid}.${localFileHandle?.name}`; const setupWatchFile = async (uuid: string, fileName: string, handle: FileSystemFileHandle) => { try { // 如没有安装纪录,将进行安装。 // 如已经安装,在FileSystemObserver检查更改前,先进行更新。 - const code = `${scriptCode}`; - await installOrUpdateScript(upsertScript as Script, code); + const code = `${currentScriptCode}`; + await installOrUpdateScript(pendingScript as Script, code); // setScriptCode(`${code}`); - setDiffCode(`${code}`); + setDiffBaseCode(`${code}`); const ftInfo: FTInfo = { uuid, fileName, @@ -629,14 +673,14 @@ function App() { } }; - useEffect(() => { - if (!watchFile || !localFileHandle) { + const handleFileWatchChange = () => { + if (!isFileWatchingEnabled || !localFileHandle) { return; } // 去除React特性 const [handle] = [localFileHandle]; unmountFileTrack(handle); // 避免重复追踪 - const uuid = scriptInfo?.uuid; + const uuid = scriptInstallConfig?.uuid; const fileName = handle?.name; if (!uuid || !fileName) { return; @@ -645,52 +689,105 @@ function App() { return () => { unmountFileTrack(handle); }; - }, [memoWatchFile]); + }; - // 检查是否有 uuid 或 file - const hasUUIDorFile = useMemo(() => { - return !!(searchParams.get("uuid") || searchParams.get("file")); - }, [searchParams]); + // 当 watch file 启用时,用于追踪本地 file 更新 + useEffect(() => { + handleFileWatchChange(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [watchFileStateIdentifier]); - const urlHref = useMemo(() => { - try { - if (!hasUUIDorFile) { - const url = searchParams.get("url"); - if (url) { + // 检查是否有 uuid 或 file + const hasValidSourceParam = !!(searchParams.get("uuid") || searchParams.get("file")); + + const targetUrlHref = useMemo(() => { + if (!hasValidSourceParam) { + /** + * 逻辑说明: + * 在 chrome.declarativeNetRequest 规则中,我们使用 `<,\1,>` 作为占位符引导 API 进行参数填充。 + * 由于不同浏览器版本或配置对 URL 参数的自动编码(Auto-encoding)策略不一致, + * 我们通过检测该占位符的“被编码状态”来逆推浏览器采用了哪种编码方式。 + */ + let m; + let url; + try { + // 场景 1:URL 完全未编码。直接匹配原始特征符号 "<", ">" 和 "," + if ((m = /\burl=(<,.+,>)(&|$)/.exec(location.search)?.[1])) { + url = m; // 未被编码,取原始值。 + } + // 场景 2:URL 经过了部分编码(类似 encodeURI)。逗号 "," 未被编码,但尖括号被转义为 %3C, %3E + else if ((m = /\burl=(%3C,.+,%3E)(&|$)/.exec(location.search)?.[1])) { + url = decodeURI(m); + } + // 场景 3:URL 经过了完全编码(类似 encodeURIComponent)。逗号也被转义为 %2C + else if ((m = /\burl=(%3C%2C.+%2C%3E)(&|$)/.exec(location.search)?.[1])) { + url = decodeURIComponent(m); + } + } catch { + // ignored + } + // 如果正则匹配/标准解码失败,回退到标准的 searchParams 获取方式 (浏览器会自行理解和解码不规范的编码) + if (!url) url = searchParams.get("url") || ""; // fallback + // 移除人工注入的特征锚点 <, ,>,提取真实的 URL 内容 + url = url.replace(/^<,(.+),>$/, "$1"); // 去掉 <, ,> + if (url) { + try { const urlObject = new URL(url); + // 验证解析后的 URL 是否具备核心要素,确保安全性与合法性 if (urlObject.protocol && urlObject.hostname && urlObject.pathname) { - return urlObject.href; + return url; } + } catch { + // ignored } } - } catch { - // ignored } return ""; - }, [hasUUIDorFile, searchParams]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasValidSourceParam, searchParams.get("url")]); const [fetchingState, setFetchingState] = useState({ - loadingStatus: "", - errorStatus: "", + loadingStatusText: "", + errorStatusText: "", }); - const loadURLAsync = async (urlHref: string) => { + const loadURLAsync = async (candidateUrls: string[]) => { + // 1. 定义获取单个脚本的内部逻辑,负责处理进度条与单次错误 + const fetchValidScript = async () => { + let firstError: unknown; + for (const url of candidateUrls) { + try { + const result = await fetchScriptBody(url, { + onProgress: (info: { receivedLength: number }) => { + setFetchingState((prev) => ({ + ...prev, + loadingStatusText: t("downloading_status_text", { bytes: formatBytes(info.receivedLength) }), + })); + }, + }); + if (result.code && result.metadata) { + return { result, url }; // 找到有效的立即返回 + } + } catch (e) { + if (!firstError) firstError = e; + } + } + // 如果循环结束都没成功,抛出第一个捕获到的错误或预设错误 + throw firstError || new Error(t("install_page_load_failed")); + }; + try { - const { code, metadata } = await fetchScriptBody(urlHref, { - onProgress: (info: { receivedLength: number }) => { - setFetchingState((prev) => ({ - ...prev, - loadingStatus: t("downloading_status_text", { bytes: `${formatBytes(info.receivedLength)}` }), - })); - }, - }); - const update = false; + // 2. 执行获取 + const { result, url } = await fetchValidScript(); + const { code, metadata } = result; + + // 3. 处理数据与缓存 const uuid = uuidv4(); - const url = urlHref; - const upsertBy = "user"; + const scriptData = [false, createScriptInfo(uuid, code, url, "user", metadata)]; - const si = [update, createScriptInfo(uuid, code, url, upsertBy, metadata)]; - await cacheInstance.set(`${CACHE_KEY_SCRIPT_INFO}${uuid}`, si); + await cacheInstance.set(`${CACHE_KEY_SCRIPT_INFO}${uuid}`, scriptData); + + // 4. 更新导向 setSearchParams( (prev) => { prev.delete("url"); @@ -700,32 +797,49 @@ function App() { { replace: true } ); } catch (err: any) { - const errMessage = `${err.message || err}`; + // 5. 统一错误处理 setFetchingState((prev) => ({ ...prev, - loadingStatus: "", - errorStatus: errMessage, + loadingStatusText: "", + errorStatusText: String(err?.message || err), })); } }; + const handleUrlChangeAndFetch = (targetUrlHref: string) => { + setFetchingState((prev) => ({ + ...prev, + loadingStatusText: t("install_page_please_wait"), + })); + const candidateUrls = getCandidateUrls(targetUrlHref); + loadURLAsync(candidateUrls); + }; + + // 有 url 的话下载内容 useEffect(() => { - if (!urlHref) return; - loadURLAsync(urlHref); - }, [urlHref]); + if (targetUrlHref) handleUrlChangeAndFetch(targetUrlHref); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [targetUrlHref]); - if (!hasUUIDorFile) { - return urlHref ? ( + if (!hasValidSourceParam) { + return targetUrlHref ? (
- {t("install_page_loading")} - {fetchingState.loadingStatus && ( -
- {fetchingState.loadingStatus} -
-
+ {fetchingState.loadingStatusText && ( + <> + {t("install_page_loading")} +
+ {fetchingState.loadingStatusText} +
+
+ + )} + {fetchingState.errorStatusText && ( + <> + {t("install_page_load_failed")} +
{fetchingState.errorStatusText}
+ )} - {fetchingState.errorStatus &&
{fetchingState.errorStatus}
}
) : ( @@ -770,7 +884,7 @@ function App() { {t("enable_background.prompt_description", { - scriptType: upsertScript?.metadata?.background ? t("background_script") : t("scheduled_script"), + scriptType: pendingScript?.metadata?.background ? t("background_script") : t("scheduled_script"), })} {t("enable_background.settings_hint")} @@ -778,29 +892,31 @@ function App() {
- {upsertScript?.metadata.icon && } - {upsertScript && ( - + {pendingScript?.metadata.icon && } + {pendingScript && ( + - {i18nName(upsertScript)} + {i18nName(pendingScript)} )} - - + +
- {oldScriptVersion && ( - - {oldScriptVersion} + {installedVersion && ( + + {installedVersion} )} - {typeof metadataLive.version?.[0] === "string" && metadataLive.version[0] !== oldScriptVersion && ( - + {typeof liveMetadata.version?.[0] === "string" && liveMetadata.version[0] !== installedVersion && ( + - {metadataLive.version[0]} + {liveMetadata.version[0]} )} @@ -812,28 +928,31 @@ function App() {
- {(metadataLive.background || metadataLive.crontab) && ( + {(liveMetadata.background || liveMetadata.crontab) && ( {t("background_script")} )} - {metadataLive.crontab && ( + {liveMetadata.crontab && ( {t("scheduled_script")} )} - {metadataLive.antifeature?.length && - metadataLive.antifeature.map((antifeature) => { + {liveMetadata.antifeature?.length && + liveMetadata.antifeature.map((antifeature) => { const item = antifeature.split(" ")[0]; return ( - antifeatures[item] && ( - - - {antifeatures[item].title} + antifeatureRegistry[item] && ( + + + {antifeatureRegistry[item].title} ) @@ -842,10 +961,10 @@ function App() {
- {upsertScript && i18nDescription(upsertScript!)} + {pendingScript && i18nDescription(pendingScript!)}
- {`${t("author")}: ${metadataLive.author}`} + {`${t("author")}: ${liveMetadata.author}`}
- {`${t("source")}: ${prettyUrl(scriptInfo?.url)}`} + {`${t("source")}: ${prettyUrl(scriptInstallConfig?.url)}`}
- {descriptionParagraph?.length ? ( + {descriptionParagraphs?.length ? (
- {descriptionParagraph} + {descriptionParagraphs}
@@ -876,7 +995,7 @@ function App() { <> )}
- {permissions.map((item) => ( + {scriptPermissions.map((item) => (
{item.value?.length > 0 ? ( <> @@ -912,36 +1031,36 @@ function App() {
- - {isUpdate ? t("update_script_no_close") : t("install_script_no_close")} + {isUpdateMode ? t("update_script_no_close") : t("install_script_no_close")} - {!scriptInfo?.userSubscribe && ( + {!scriptInstallConfig?.userSubscribe && ( - {isUpdate ? t("update_script_no_more_update") : t("install_script_no_more_update")} + {isUpdateMode ? t("update_script_no_more_update") : t("install_script_no_more_update")} )} } position="bottom" - disabled={watchFile} + disabled={isFileWatchingEnabled} > - )} - {isUpdate ? ( + {isUpdateMode ? (
diff --git a/src/pkg/utils/url-utils.test.ts b/src/pkg/utils/url-utils.test.ts new file mode 100644 index 000000000..2ede754a1 --- /dev/null +++ b/src/pkg/utils/url-utils.test.ts @@ -0,0 +1,476 @@ +import { describe, it, expect } from "vitest"; +import { toEncodedURL, prettyUrl } from "./url-utils"; // Update with your actual file path + +describe.concurrent("toEncodedURL() Comprehensive Suite", () => { + describe.concurrent("Core Functionality & Sanitization", () => { + it.concurrent("should handle standard URLs without changes", () => { + const input = "https://example.com/path/to/page?query=1"; + expect(toEncodedURL(input)).toBe("https://example.com/path/to/page?query=1"); + }); + + it.concurrent("should normalize backslashes to forward slashes", () => { + const input = "https:\\\\example.com\\path\\file.txt"; + // URL constructor normalizes host and path separators + expect(toEncodedURL(input)).toBe("https://example.com/path/file.txt"); + }); + + it.concurrent("should preserve non-standard ports", () => { + const input = "https://localhost:8080/api/v1"; + expect(toEncodedURL(input)).toBe("https://localhost:8080/api/v1"); + }); + + it.concurrent("should handle IPv6 addresses with ports", () => { + const input = "http://[2001:db8::1]:8080/path"; + expect(toEncodedURL(input)).toBe("http://[2001:db8::1]:8080/path"); + }); + }); + + describe.concurrent("Punycode & International Domains (IDN)", () => { + it.concurrent("should convert CJK domains to Punycode", () => { + // "点看" (click/see) + const input = "https://点看.com/path"; + const result = toEncodedURL(input); + expect(result).toBe("https://xn--3pxu8k.com/path"); + }); + + it.concurrent("should handle German Umlauts in domains", () => { + const input = "https://müller.de"; + // muller.de -> xn--mller-kva.de + expect(toEncodedURL(input)).toContain("xn--mller-kva.de"); + }); + + it.concurrent("should handle complex CJK domains with ports", () => { + const input = "https://测试.机构:9000/home"; + const result = toEncodedURL(input); + expect(result).toBe("https://xn--0zwm56d.xn--nqv7f:9000/home"); + }); + }); + + describe.concurrent("International Character Encoding (Path vs Query)", () => { + it.concurrent("should encode CJK characters in path and query", () => { + const input = "https://example.com/测试?q=你好"; + const result = toEncodedURL(input); + // Path: /%E6%B5%8B%E8%AF%95 Query: q=%E4%BD%A0%E5%A5%BD + expect(result).toBe("https://example.com/%E6%B5%8B%E8%AF%95?q=%E4%BD%A0%E5%A5%BD"); + }); + + it.concurrent("should handle Latin accents and Foreign scripts", () => { + // French "crème", Russian "москва", Arabic "مرحبا" + const input = "https://example.com/crème/москва?lang=مرحبا"; + const result = toEncodedURL(input); + expect(result).toContain("%C3%A8me"); // crème + expect(result).toContain("%D0%BC%D0%BE%D1%81%D0%BA%D0%B2%D0%B0"); // москва + expect(result).toContain("%D9%85%D8%B1%D8%AD%D8%A8%D8%A7"); // مرحبا + }); + + it.concurrent("should correctly encode Emojis and ZWJ sequences", () => { + // 🚀 and 👨‍👩‍👧 (Family) + const input = "https://example.com/🚀?user=👨‍👩‍👧"; + const result = toEncodedURL(input); + expect(result).toContain("%F0%9F%9A%80"); // 🚀 + expect(decodeURIComponent(result)).toContain("👨‍👩‍👧"); + }); + }); + + describe.concurrent("Spacing & Special Whitespace", () => { + it.concurrent("should encode standard spaces as %20", () => { + const input = "https://example.com/path with space?name=john doe"; + expect(toEncodedURL(input)).toBe("https://example.com/path%20with%20space?name=john%20doe"); + }); + + it.concurrent("should handle CJK Ideographic Spaces (U+3000)", () => { + const input = "https://example.com/CJK Space"; + expect(toEncodedURL(input)).toContain("%E3%80%80"); + }); + + it.concurrent("should handle non-breaking spaces (U+00A0)", () => { + const input = "https://example.com/non\u00A0breaking"; + expect(toEncodedURL(input)).toContain("%C2%A0"); + }); + }); + + describe.concurrent("Encoding Resilience (Double-Encoding Prevention)", () => { + it.concurrent("should not double-encode already encoded Latin/CJK components", () => { + // %20 is already encoded, but 'test' is not + const input = "https://example.com/already%20encoded/测试"; + const result = toEncodedURL(input); + expect(result).toBe("https://example.com/already%20encoded/%E6%B5%8B%E8%AF%95"); + }); + + it.concurrent("should fix partially malformed encoded strings", () => { + // The % at the end is invalid; the function should catch the error and encode it.concurrent + const input = "https://example.com/search?q=100%"; + expect(toEncodedURL(input)).toBe("https://example.com/search?q=100%25"); + }); + }); + + describe.concurrent("Edge Cases", () => { + it.concurrent("should handle URL fragments (#) correctly", () => { + const input = "https://example.com/page?query=1#section-1"; + expect(toEncodedURL(input)).toBe("https://example.com/page?query=1#section-1"); + }); + + it.concurrent("should throw if the input is not a valid URL format", () => { + const input = "this-is-not-a-url"; + expect(() => toEncodedURL(input)).toThrow(); + }); + }); + + describe.concurrent("WHATWG URL Normalization Edge Cases", () => { + it.concurrent("should remove default port 80 for http", () => { + const input = "http://example.com:80/path"; + expect(toEncodedURL(input)).toBe("http://example.com/path"); + }); + + it.concurrent("should remove default port 443 for https", () => { + const input = "https://example.com:443/path"; + expect(toEncodedURL(input)).toBe("https://example.com/path"); + }); + + it.concurrent("should lowercase hostnames", () => { + const input = "https://EXAMPLE.COM/Path"; + expect(toEncodedURL(input)).toContain("example.com"); + }); + + it.concurrent("should resolve dot segments", () => { + const input = "https://example.com/a/b/../c/./d"; + expect(toEncodedURL(input)).toBe("https://example.com/a/c/d"); + }); + + it.concurrent("should auto-add trailing slash for bare origin", () => { + const input = "https://example.com"; + expect(toEncodedURL(input)).toBe("https://example.com/"); + }); + }); + describe.concurrent("Username / Password Handling", () => { + it.concurrent("should preserve username and password", () => { + const input = "https://user:pass@example.com/path"; + expect(toEncodedURL(input)).toBe("https://user:pass@example.com/path"); + }); + + it.concurrent("should encode special chars in username", () => { + const input = "https://us er:p@ss@example.com/"; + const result = toEncodedURL(input); + expect(result).toContain("us%20er"); + }); + }); + + describe.concurrent("Encoded Delimiters", () => { + it.concurrent("should preserve encoded slash in path", () => { + const input = "https://example.com/a%2Fb/c"; + expect(toEncodedURL(input)).toBe("https://example.com/a%2Fb/c"); + }); + + it.concurrent("should preserve encoded question mark in path", () => { + const input = "https://example.com/a%3Fb"; + expect(toEncodedURL(input)).toBe("https://example.com/a%3Fb"); + }); + + it.concurrent("should preserve encoded hash in path", () => { + const input = "https://example.com/a%23b"; + expect(toEncodedURL(input)).toBe("https://example.com/a%23b"); + }); + }); + + describe.concurrent("Duplicate Query Keys", () => { + it.concurrent("should preserve duplicate keys order", () => { + const input = "https://example.com?a=1&a=2&a=3"; + expect(toEncodedURL(input)).toBe("https://example.com/?a=1&a=2&a=3"); + }); + }); + + describe.concurrent("Empty Query Edge Cases", () => { + it.concurrent("should handle empty query key", () => { + const input = "https://example.com/?=value"; + expect(toEncodedURL(input)).toBe("https://example.com/?=value"); + }); + + it.concurrent("should handle empty value", () => { + const input = "https://example.com/?key="; + expect(toEncodedURL(input)).toBe("https://example.com/?key="); + }); + + it.concurrent("should handle bare question mark", () => { + const input = "https://example.com/?"; + expect(toEncodedURL(input)).toBe("https://example.com/?"); + }); + }); + + describe.concurrent("Fragment Encoding", () => { + it.concurrent("should encode unicode in hash", () => { + const input = "https://example.com/#测试"; + const result = toEncodedURL(input); + expect(result).toContain("#%E6%B5%8B%E8%AF%95"); + }); + + it.concurrent("should preserve already encoded hash", () => { + const input = "https://example.com/#%E6%B5%8B"; + expect(toEncodedURL(input)).toBe("https://example.com/#%E6%B5%8B"); + }); + }); + + describe.concurrent("Idempotence Guarantee", () => { + it.concurrent("should return identical result when run twice", () => { + const input = "https://example.com/测试?q=hello world#片段"; + const once = toEncodedURL(input); + const twice = toEncodedURL(once); + expect(twice).toBe(once); + }); + }); + + describe.concurrent("Invalid Percent Encodings", () => { + it.concurrent("should encode stray percent", () => { + const input = "https://example.com/100%complete"; + expect(toEncodedURL(input)).toContain("100%25complete"); + }); + + it.concurrent("should fix broken percent sequence", () => { + const input = "https://example.com/%E0%A4%A"; + const result = toEncodedURL(input); + expect(result).toContain("%25E0"); // or properly re-encoded + }); + }); +}); + +describe.concurrent("prettyUrl", () => { + describe.concurrent("Domain / Punycode Handling", () => { + it.concurrent("should decode CJK Punycode domains", () => { + expect(prettyUrl("http://xn--6qq79v.com")).toBe("http://你好.com/"); + }); + + it.concurrent("should decode Emoji domains", () => { + expect(prettyUrl("https://xn--vi8h.la/path")).toBe("https://🍕.la/path"); + }); + + it.concurrent("should handle mixed Latin and Foreign scripts", () => { + expect(prettyUrl("http://xn--maana-pta.com")).toBe("http://mañana.com/"); + }); + }); + + describe.concurrent("Path and Percent Encoding", () => { + it.concurrent("should decode CJK characters in the pathname", () => { + // %E6%B5%8B%E8%AF%95 -> 测试 + expect(prettyUrl("https://example.com/%E6%B5%8B%E8%AF%95")).toBe("https://example.com/测试"); + }); + + it.concurrent("should decode spaces and common symbols in path", () => { + expect(prettyUrl("https://site.com/hello%20world")).toBe("https://site.com/hello world"); + }); + + it.concurrent("should NOT decode if it.concurrent introduces reserved URL delimiters like ? or #", () => { + // If %3F (?) is decoded inside the path, it.concurrent breaks the URL structure + const input = "https://example.com/path%3Fquery"; + expect(prettyUrl(input)).toBe(input); + }); + }); + + describe.concurrent("Search and Hash Parameters", () => { + it.concurrent("should decode complex query strings while preserving & and =", () => { + const input = "https://google.com/search?q=%E4%BD%A0%E5%A5%BD&hl=zh"; + expect(prettyUrl(input)).toBe("https://google.com/search?q=你好&hl=zh"); + }); + + it.concurrent("should decode fragments (hashes)", () => { + expect(prettyUrl("https://wiki.org/Main#%E7%BB%93%E8%AE%BA")).toBe("https://wiki.org/Main#结论"); + }); + }); + + describe.concurrent("Edge Cases and Safety", () => { + it.concurrent("should return empty string for null/undefined", () => { + expect(prettyUrl(null as any)).toBe(""); + expect(prettyUrl(undefined)).toBe(""); + }); + + it.concurrent("should return the original string if it.concurrent is not a valid URL", () => { + const invalid = "not-a-url-at-all"; + expect(prettyUrl(invalid)).toBe(invalid); + }); + + it.concurrent("should handle ports correctly", () => { + expect(prettyUrl("http://localhost:8080/test")).toBe("http://localhost:8080/test"); + }); + + it.concurrent("should handle URLs with base URLs provided", () => { + expect(prettyUrl("/path?q=%E2%9C%85", "https://base.com")).toBe("https://base.com/path?q=✅"); + }); + + it.concurrent("should fail gracefully on malformed percent encoding", () => { + // %E4 is an incomplete sequence for a 3-byte UTF-8 char + const malformed = "https://example.com/%E4%BD"; + expect(prettyUrl(malformed)).toBe(malformed); + }); + }); + + describe.concurrent("Internationalization / Foreign Languages", () => { + it.concurrent("should handle RTL (Right-to-Left) scripts like Arabic", () => { + // xn--ngbo2ef is part of an Arabic domain string + expect(prettyUrl("http://xn--ngbo2ef.com/%D9%85%D8%B1%D8%AD%D8%A8%D8%A7")).toBe("http://بنده.com/مرحبا"); + }); + }); + + describe.concurrent("Idempotence", () => { + it.concurrent("should produce identical result when run twice", () => { + const input = "https://xn--6qq79v.com/%E6%B5%8B%E8%AF%95?q=%F0%9F%9A%80"; + const once = prettyUrl(input); + const twice = prettyUrl(once); + expect(twice).toBe(once); + }); + }); + + describe.concurrent("Encoded Structural Characters", () => { + it.concurrent("should not decode encoded slash in path", () => { + const input = "https://example.com/a%2Fb"; + expect(prettyUrl(input)).toBe(input); + }); + + it.concurrent("should not decode encoded ampersand in query value", () => { + const input = "https://example.com/?q=hello%26world"; + expect(prettyUrl(input)).toBe(input); + }); + + it.concurrent("should not decode encoded equals in query value", () => { + const input = "https://example.com/?q=a%3Db"; + expect(prettyUrl(input)).toBe(input); + }); + + it.concurrent("should not decode encoded hash in path", () => { + const input = "https://example.com/a%23b"; + expect(prettyUrl(input)).toBe(input); + }); + }); + + describe.concurrent("IPv6 and Authority Edge Cases", () => { + // it.concurrent("should preserve IPv6 host", () => { + // const input = "http://[2001:db8::1]/%E6%B5%8B%E8%AF%95"; + // expect(prettyUrl(input)).toBe("http://[2001:db8::1]/测试"); + // }); + + it.concurrent("should preserve username and password", () => { + const input = "https://user:pass@xn--6qq79v.com/%E6%B5%8B"; + expect(prettyUrl(input)).toBe("https://user:pass@你好.com/测"); + }); + }); + + describe.concurrent("Duplicate Query Keys", () => { + it.concurrent("should preserve duplicate query parameters", () => { + const input = "https://example.com/?a=1&a=2&a=3"; + expect(prettyUrl(input)).toBe("https://example.com/?a=1&a=2&a=3"); + }); + }); + + describe.concurrent("Complex Unicode Sequences", () => { + it.concurrent("should decode emoji ZWJ sequences", () => { + const input = "https://example.com/%F0%9F%91%A8%E2%80%8D%F0%9F%91%A9%E2%80%8D%F0%9F%91%A7"; + expect(prettyUrl(input)).toBe("https://example.com/👨‍👩‍👧"); + }); + + it.concurrent("should handle combining diacritics correctly", () => { + // e + combining acute accent + const input = "https://example.com/e%CC%81"; + expect(prettyUrl(input)).toBe("https://example.com/é"); + }); + }); + + // describe.concurrent("Mixed Encoded + Unencoded Segments", () => { + // it.concurrent("should decode only safe segments", () => { + // const input = "https://example.com/%E6%B5%8B%E8%AF%95%2Fsafe"; + // // %2F should NOT decode + // expect(prettyUrl(input)).toBe(input); + // }); + // }); + + describe.concurrent("Dot Segment Awareness", () => { + it.concurrent("should not alter already normalized paths", () => { + const input = "https://example.com/a/b/../c"; + const pretty = prettyUrl(input); + expect(pretty).toBe("https://example.com/a/c"); + }); + }); + + describe.concurrent("Default Port Handling", () => { + it.concurrent("should remove default port for http", () => { + expect(prettyUrl("http://example.com:80/")).toBe("http://example.com/"); + }); + + it.concurrent("should remove default port for https", () => { + expect(prettyUrl("https://example.com:443/")).toBe("https://example.com/"); + }); + }); + + describe.concurrent("Empty Query Edge Cases", () => { + it.concurrent("should preserve empty value", () => { + const input = "https://example.com/?key="; + expect(prettyUrl(input)).toBe("https://example.com/?key="); + }); + + it.concurrent("should preserve empty query key", () => { + const input = "https://example.com/?=value"; + expect(prettyUrl(input)).toBe("https://example.com/?=value"); + }); + }); + + describe.concurrent("Trailing Slash Consistency", () => { + it.concurrent("should preserve trailing slash", () => { + const input = "https://example.com/path/"; + expect(prettyUrl(input)).toBe("https://example.com/path/"); + }); + + it.concurrent("should auto-add slash for bare origin", () => { + expect(prettyUrl("https://example.com")).toBe("https://example.com/"); + }); + }); + + describe.concurrent("Hash / Fragment Edge Cases", () => { + it.concurrent("should preserve empty fragment", () => { + const input = "https://example.com/#"; + expect(prettyUrl(input)).toBe("https://example.com/#"); + }); + + it.concurrent("should decode unicode inside fragment", () => { + const input = "https://example.com/#%F0%9F%9A%80"; + expect(prettyUrl(input)).toBe("https://example.com/#🚀"); + }); + + it.concurrent("should NOT decode encoded hash inside fragment", () => { + // Decoding %23 inside fragment would create a second fragment delimiter + const input = "https://example.com/#section%231"; + expect(prettyUrl(input)).toBe(input); + }); + + it.concurrent("should NOT decode encoded question mark inside fragment", () => { + // Avoid introducing query semantics inside fragment + const input = "https://example.com/#part%3Fquery"; + expect(prettyUrl(input)).toBe(input); + }); + + it.concurrent("should decode safe characters but preserve encoded structural ones in fragment", () => { + const input = "https://example.com/#hello%20world%23anchor"; + expect(prettyUrl(input)).toBe(input); + }); + + it.concurrent("should handle fragment with duplicate-like semantics safely", () => { + const input = "https://example.com/#a=1&a=2"; + expect(prettyUrl(input)).toBe("https://example.com/#a=1&a=2"); + }); + + it.concurrent("should preserve fragment when base URL is used", () => { + expect(prettyUrl("/path#%E2%9C%85", "https://base.com")).toBe("https://base.com/path#✅"); + }); + + it.concurrent("should not double-decode fragment", () => { + const input = "https://example.com/#%2523"; + const once = prettyUrl(input); + const twice = prettyUrl(once); + expect(twice).toBe(once); + }); + + it.concurrent("should handle fragment-only URL with base", () => { + expect(prettyUrl("#%E7%BB%93%E6%9E%9C", "https://example.com/page")).toBe("https://example.com/page#结果"); + }); + + it.concurrent("should preserve encoded slash inside fragment", () => { + const input = "https://example.com/#a%2Fb"; + expect(prettyUrl(input)).toBe(input); + }); + }); +}); diff --git a/src/pkg/utils/url-utils.ts b/src/pkg/utils/url-utils.ts new file mode 100644 index 000000000..e544bc1a5 --- /dev/null +++ b/src/pkg/utils/url-utils.ts @@ -0,0 +1,214 @@ +const urlSplit = (url: string) => { + let s = url.split(/(:?\/{2,3}|:?\\{2,3}|[\\/?#])/g); + const i = s.indexOf("?"); + if (i > 0 && s[i + 1]) { + const search = s[i + 1]; + s = [...s.slice(0, i), "?", ...search.split(/([&=])/g), ...s.slice(i + 2)]; + } + return s; +}; + +export const toEncodedURL = (inputUrl: string) => { + const STATE = { + INITIAL: 0, + PATHNAME: 1, + SEARCH: 2, + HASH: 3, + } as const; + + type STATE = ValueOf; + // 1. let URL sanitize the input + // 2. Do manual encoding if URL did not encoded correctly. + const sanitizedInput = new URL(inputUrl); + const originalSplit = urlSplit(inputUrl); + const newSplit = urlSplit(sanitizedInput.href); + let state = 0; + let pathStartFrom = +Infinity; + try { + if (originalSplit.length !== newSplit.length) { + // https://example.com?a=1&a=2&a=3 -> https://example.com/?a=1&a=2&a=3 + throw new Error("mismatch"); + } + const finalSanitizedUrl = newSplit + .map((x, i) => { + if ((i & 1) === 1) if (newSplit[i] !== originalSplit[i]) throw new Error("mismatch"); + if (i < pathStartFrom) { + if ((i & 1) === 1 && (x.endsWith("//") || x.endsWith("\\\\"))) pathStartFrom = i + 2; + return x; + } + if ((i & 1) === 1) { + if (state === STATE.INITIAL && x === "?") state = STATE.SEARCH; + else if (state === STATE.INITIAL && x === "#") state = STATE.HASH; + else if (state === STATE.SEARCH && x === "#") state = STATE.HASH; + return x; + } + if (x.includes("%")) { + if (state === STATE.SEARCH) { + try { + decodeURIComponent(x); + return x; + } catch { + //ignored + } + } else { + try { + decodeURI(x); + return x; + } catch { + //ignored + } + } + } + const ori = originalSplit[i]; + /* + encodeURI allows: + [A-Za-z0-9\-_.!~*'();,/?:@&=+$#] + encodeURIComponent allows: + [A-Za-z0-9\-_.!~*'()] + */ + // const testRe = pathEnded ? /^[%\w\\.!~*'()-]+$/ : /^[%\w\\.!~*'();,/?:@&=+$#-]+$/; + // const isEncoded = testRe.test(ori); + // if (isEncoded) return ori; + return state === STATE.SEARCH ? encodeURIComponent(ori) : encodeURI(ori); + }) + .join(""); + return finalSanitizedUrl; + } catch { + return sanitizedInput.href; + } +}; + +/** + * Decodes Punycode (RFC 3492) + * Fixed: logic for _adapt and _basicToDigit to match RFC specifications. + */ +const Punycode = { + BASE: 36, + TMIN: 1, + TMAX: 26, + SKEW: 38, + DAMP: 700, + INITIAL_BIAS: 72, + INITIAL_N: 128, + + decode(input: string) { + // Punycode is case-insensitive; handle labels individually + const string = input.toLowerCase().startsWith("xn--") ? input.slice(4) : input; + + let n: number = this.INITIAL_N; + let i = 0; + let bias: number = this.INITIAL_BIAS; + const output: number[] = []; + + const lastDelimiter = string.lastIndexOf("-"); + if (lastDelimiter > 0) { + for (let j = 0; j < lastDelimiter; j++) { + output.push(string.charCodeAt(j)); + } + } + + let pos = lastDelimiter >= 0 ? lastDelimiter + 1 : 0; + while (pos < string.length) { + const oldI = i; + let w = 1; + + for (let k = this.BASE; ; k += this.BASE) { + const digit = this._basicToDigit(string.charCodeAt(pos++)); + i += digit * w; + const t = k <= bias ? this.TMIN : k >= bias + this.TMAX ? this.TMAX : k - bias; + if (digit < t) break; + w *= this.BASE - t; + } + + const h = output.length + 1; + bias = this._adapt(i - oldI, h, oldI === 0); + n += Math.floor(i / h); + i %= h; + + output.splice(i++, 0, n); + } + + return String.fromCodePoint(...output); + }, + + _basicToDigit(code: number) { + if (code >= 48 && code <= 57) return code - 22; // 0-9 -> 26-35 + if (code >= 65 && code <= 90) return code - 65; // A-Z -> 0-25 + if (code >= 97 && code <= 122) return code - 97; // a-z -> 0-25 + return this.BASE; + }, + + _adapt(delta: number, numPoints: number, firstTime: boolean) { + delta = firstTime ? Math.floor(delta / this.DAMP) : delta >> 1; + delta += Math.floor(delta / numPoints); + let k = 0; + const d = this.BASE - this.TMIN; + const threshold = Math.floor((d * this.TMAX) / 2); + while (delta > threshold) { + delta = Math.floor(delta / d); + k += this.BASE; + } + return k + Math.floor(((d + 1) * delta) / (delta + this.SKEW)); + }, +} as const; + +/** + * Converts a machine-encoded URL into a human-readable format. + */ + +export const prettyUrl = (s: string | undefined | null, baseUrl?: string): string => { + if (!s) return ""; + + const EXTRA = { + DECODE_URI: 0, + DECODE_COMP: 1, + PRESERVE_Q: 2, + PRESERVE_H: 4, + } as const; + const safeDecode = (val: string, extra: number) => { + try { + const decodeFn = extra & EXTRA.DECODE_COMP ? decodeURIComponent : decodeURI; + let decoded = decodeFn(val); + // Re-encode delimiters to prevent breaking the URL structure + if (extra & EXTRA.PRESERVE_Q) decoded = decoded.replace(/[=&]/g, encodeURIComponent); + if (extra & EXTRA.PRESERVE_H) decoded = decoded.replace(/ /g, encodeURIComponent); + return decoded; + } catch { + return val; + } + }; + + try { + const u = new URL(s, baseUrl); + + // 1. Core components: Protocol, Punycode Host, and Port + const protocol = u.protocol ? `${u.protocol}//` : ""; + const host = u.hostname + .split(".") + .map((p) => (p.startsWith("xn--") ? Punycode.decode(p) : p)) + .join("."); + const port = u.port ? `:${u.port}` : ""; + + // 2. Decode Path and Hash safely + const path = safeDecode(u.pathname, EXTRA.DECODE_URI); + let hash = safeDecode(u.hash, EXTRA.DECODE_URI | EXTRA.PRESERVE_H); + if (!hash && s.endsWith("#")) hash = "#"; + + // 3. Search Params: Decode key/value pairs while escaping delimiters + const params = Array.from(new URLSearchParams(u.search)); + const m = params.map( + ([k, v]) => + `${safeDecode(k, EXTRA.DECODE_COMP | EXTRA.PRESERVE_Q)}=${safeDecode(v, EXTRA.DECODE_COMP | EXTRA.PRESERVE_Q)}` + ); + const search = params.length ? `?${m.join("&")}` : ""; + + // 4. Auth: User and Password + const user = safeDecode(u.username, EXTRA.DECODE_COMP); + const pass = safeDecode(u.password, EXTRA.DECODE_COMP); + const auth = user ? `${user}${pass ? `:${pass}` : ""}@` : ""; + + return `${protocol}${auth}${host}${port}${path}${search}${hash}`; + } catch { + return s; + } +}; diff --git a/src/pkg/utils/utils.ts b/src/pkg/utils/utils.ts index 4ca023d9e..9b9ff2fe4 100644 --- a/src/pkg/utils/utils.ts +++ b/src/pkg/utils/utils.ts @@ -460,40 +460,6 @@ export const formatBytes = (bytes: number, decimals: number = 2): string => { return `${value.toFixed(decimals)} ${units[i]}`; }; -// 把编码URL变成使用者可以阅读的格式 -export const prettyUrl = (s: string | undefined | null, baseUrl?: string) => { - if (s?.includes("://")) { - let u; - try { - u = baseUrl ? new URL(s, baseUrl) : new URL(s); - } catch { - // ignored - } - if (!u) return s; - const pathname = u.pathname; - if (pathname && pathname.includes("%")) { - try { - const raw = decodeURI(pathname); - if ( - raw && - raw.length < pathname.length && - !raw.includes("?") && - !raw.includes("#") && - !raw.includes("&") && - !raw.includes("=") && - !raw.includes("%") && - !raw.includes(":") - ) { - s = s.replace(pathname, raw); - } - } catch { - // ignored - } - } - } - return s; -}; - // TM Xhr Header 兼容处理,原生xhr \r\n 在尾,但TM的GMXhr没有;同时除去冒号后面的空白 export const normalizeResponseHeaders = (headersString: string) => { if (!headersString) return ""; From c2903d45c1e56481a291913ca097f14b3d2840f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 13 Feb 2026 22:00:13 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E5=BC=95=E5=85=A5Punycode=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit ec65ce1d629d0e5442878ea9b42fa98fff00f142) --- package.json | 1 + pnpm-lock.yaml | 18 ++++++++ src/pkg/utils/url-utils.ts | 86 ++++++-------------------------------- 3 files changed, 32 insertions(+), 73 deletions(-) diff --git a/package.json b/package.json index fa7228c66..ebf1b80f8 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "eventemitter3": "^5.0.1", "i18next": "^23.16.4", "monaco-editor": "^0.52.2", + "punycode": "^2.3.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-dropzone": "^14.3.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5012eaaec..7ceb42212 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: monaco-editor: specifier: ^0.52.2 version: 0.52.2 + punycode: + specifier: ^2.3.1 + version: 2.3.1 react: specifier: ^18.3.1 version: 18.3.1 @@ -982,56 +985,67 @@ packages: resolution: {integrity: sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.44.2': resolution: {integrity: sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.44.2': resolution: {integrity: sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.44.2': resolution: {integrity: sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.44.2': resolution: {integrity: sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.44.2': resolution: {integrity: sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.44.2': resolution: {integrity: sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.44.2': resolution: {integrity: sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.44.2': resolution: {integrity: sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.44.2': resolution: {integrity: sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.44.2': resolution: {integrity: sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.44.2': resolution: {integrity: sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw==} @@ -1062,21 +1076,25 @@ packages: resolution: {integrity: sha512-eQfcsaxhFrv5FmtaA7+O1F9/2yFDNIoPZzV/ZvqvFz5bBXVc4FAm/1fVpBg8Po/kX1h0chBc7Xkpry3cabFW8w==} cpu: [arm64] os: [linux] + libc: [glibc] '@rspack/binding-linux-arm64-musl@1.7.6': resolution: {integrity: sha512-DfQXKiyPIl7i1yECHy4eAkSmlUzzsSAbOjgMuKn7pudsWf483jg0UUYutNgXSlBjc/QSUp7906Cg8oty9OfwPA==} cpu: [arm64] os: [linux] + libc: [musl] '@rspack/binding-linux-x64-gnu@1.7.6': resolution: {integrity: sha512-NdA+2X3lk2GGrMMnTGyYTzM3pn+zNjaqXqlgKmFBXvjfZqzSsKq3pdD1KHZCd5QHN+Fwvoszj0JFsquEVhE1og==} cpu: [x64] os: [linux] + libc: [glibc] '@rspack/binding-linux-x64-musl@1.7.6': resolution: {integrity: sha512-rEy6MHKob02t/77YNgr6dREyJ0e0tv1X6Xsg8Z5E7rPXead06zefUbfazj4RELYySWnM38ovZyJAkPx/gOn3VA==} cpu: [x64] os: [linux] + libc: [musl] '@rspack/binding-wasm32-wasi@1.7.6': resolution: {integrity: sha512-YupOrz0daSG+YBbCIgpDgzfMM38YpChv+afZpaxx5Ml7xPeAZIIdgWmLHnQ2rts73N2M1NspAiBwV00Xx0N4Vg==} diff --git a/src/pkg/utils/url-utils.ts b/src/pkg/utils/url-utils.ts index e544bc1a5..66e247feb 100644 --- a/src/pkg/utils/url-utils.ts +++ b/src/pkg/utils/url-utils.ts @@ -1,3 +1,5 @@ +import { decode as punycodeDecode } from "punycode"; + const urlSplit = (url: string) => { let s = url.split(/(:?\/{2,3}|:?\\{2,3}|[\\/?#])/g); const i = s.indexOf("?"); @@ -78,79 +80,17 @@ export const toEncodedURL = (inputUrl: string) => { } }; -/** - * Decodes Punycode (RFC 3492) - * Fixed: logic for _adapt and _basicToDigit to match RFC specifications. - */ -const Punycode = { - BASE: 36, - TMIN: 1, - TMAX: 26, - SKEW: 38, - DAMP: 700, - INITIAL_BIAS: 72, - INITIAL_N: 128, - - decode(input: string) { - // Punycode is case-insensitive; handle labels individually - const string = input.toLowerCase().startsWith("xn--") ? input.slice(4) : input; - - let n: number = this.INITIAL_N; - let i = 0; - let bias: number = this.INITIAL_BIAS; - const output: number[] = []; - - const lastDelimiter = string.lastIndexOf("-"); - if (lastDelimiter > 0) { - for (let j = 0; j < lastDelimiter; j++) { - output.push(string.charCodeAt(j)); - } - } - - let pos = lastDelimiter >= 0 ? lastDelimiter + 1 : 0; - while (pos < string.length) { - const oldI = i; - let w = 1; - - for (let k = this.BASE; ; k += this.BASE) { - const digit = this._basicToDigit(string.charCodeAt(pos++)); - i += digit * w; - const t = k <= bias ? this.TMIN : k >= bias + this.TMAX ? this.TMAX : k - bias; - if (digit < t) break; - w *= this.BASE - t; - } - - const h = output.length + 1; - bias = this._adapt(i - oldI, h, oldI === 0); - n += Math.floor(i / h); - i %= h; +function domainPunycodeDecode(s: string): string { + if (!s.startsWith("xn--")) return s; - output.splice(i++, 0, n); - } - - return String.fromCodePoint(...output); - }, - - _basicToDigit(code: number) { - if (code >= 48 && code <= 57) return code - 22; // 0-9 -> 26-35 - if (code >= 65 && code <= 90) return code - 65; // A-Z -> 0-25 - if (code >= 97 && code <= 122) return code - 97; // a-z -> 0-25 - return this.BASE; - }, - - _adapt(delta: number, numPoints: number, firstTime: boolean) { - delta = firstTime ? Math.floor(delta / this.DAMP) : delta >> 1; - delta += Math.floor(delta / numPoints); - let k = 0; - const d = this.BASE - this.TMIN; - const threshold = Math.floor((d * this.TMAX) / 2); - while (delta > threshold) { - delta = Math.floor(delta / d); - k += this.BASE; - } - return k + Math.floor(((d + 1) * delta) / (delta + this.SKEW)); - }, -} as const; + try { + // 截取 "xn--" 前缀后进行解码 + const punycodePart = s.slice(4); + return punycodeDecode(punycodePart); + } catch { + return s; + } +} /** * Converts a machine-encoded URL into a human-readable format. @@ -185,7 +125,7 @@ export const prettyUrl = (s: string | undefined | null, baseUrl?: string): strin const protocol = u.protocol ? `${u.protocol}//` : ""; const host = u.hostname .split(".") - .map((p) => (p.startsWith("xn--") ? Punycode.decode(p) : p)) + .map((p) => domainPunycodeDecode(p)) .join("."); const port = u.port ? `:${u.port}` : ""; From fc7b701354742970a4fc462d072543fc00f543f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 13 Feb 2026 22:19:49 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E7=AE=80=E5=8C=96=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/script.ts | 10 +- src/pages/install/App.tsx | 139 +++----------- src/pkg/utils/url-utils.test.ts | 233 +---------------------- src/pkg/utils/url-utils.ts | 80 -------- 4 files changed, 27 insertions(+), 435 deletions(-) diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 2d610bcfd..227be28d1 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -266,15 +266,7 @@ export class ScriptService { action: { type: "redirect" as chrome.declarativeNetRequest.RuleActionType, redirect: { - /** - * 核心设计: - * 使用 `<,\1,>` 作为特征锚点注入到重定向 URL 中。 - * 1. 引导格式化:利用 \1 提取正则捕获组内容。 - * 2. 编码探测:通过包裹特殊的定界符(尖括号和逗号),在目标页面解析时, - * 可以通过检测这些字符是否被转义(如变为 %3C, %2C)来精准判定 - * 浏览器底层触发的是哪种 URL 编码策略(Raw / encodeURI / encodeURIComponent)。 - */ - regexSubstitution: `${installPageURL}?url=<,\\1,>`, + regexSubstitution: `${installPageURL}?url=\\1`, }, }, condition: condition, diff --git a/src/pages/install/App.tsx b/src/pages/install/App.tsx index 8b3e661f3..b481e74d6 100644 --- a/src/pages/install/App.tsx +++ b/src/pages/install/App.tsx @@ -34,7 +34,7 @@ import { cacheInstance } from "@App/app/cache"; import { formatBytes } from "@App/pkg/utils/utils"; import { ScriptIcons } from "../options/routes/utils"; import { bytesDecode, detectEncoding } from "@App/pkg/utils/encoding"; -import { toEncodedURL, prettyUrl } from "@App/pkg/utils/url-utils"; +import { prettyUrl } from "@App/pkg/utils/url-utils"; const backgroundPromptShownKey = "background_prompt_shown"; @@ -57,48 +57,6 @@ const closeWindow = (shouldGoBack: boolean) => { } }; -const getCandidateUrls = (targetUrlHref: string) => { - const encodedUrl = toEncodedURL(targetUrlHref); - const inputU = new URL(encodedUrl); - const extraCandidateUrls = new Set(); - extraCandidateUrls.add(inputU.href); - - const hostname = inputU.hostname; - // 兼容 .greasyfork.org, cn-greasyfork.org - const hostText = `.${hostname}`.replace(/\W/g, "."); - const isGreasyFork = hostText.endsWith(".greasyfork.org"); - const isSleazyFork = hostText.endsWith(".sleazyfork.org"); - - if (isGreasyFork || isSleazyFork) { - // example: - // CASE 1 - // raw 'https://update.greasyfork.org/scripts/550295/100%解锁CSDN文库vip文章阅读限制.user.js' - // encoded 'https://update.greasyfork.org/scripts/550295/100%25%E8%A7%A3%E9%94%81CSDN%E6%96%87%E5%BA%93vip%E6%96%87%E7%AB%A0%E9%98%85%E8%AF%BB%E9%99%90%E5%88%B6.user.js' - // correct 'https://update.greasyfork.org/scripts/550295/100%25%E8%A7%A3%E9%94%81CSDN%E6%96%87%E5%BA%93vip%E6%96%87%E7%AB%A0%E9%98%85%E8%AF%BB%E9%99%90%E5%88%B6.user.js' - // CASE 2 - // raw 'https://update.greasyfork.org/scripts/519037/Nexus No Wait ++.user.js' - // encoded 'https://update.greasyfork.org/scripts/519037/Nexus%20No%20Wait%20++.user.js' - // correct 'https://update.greasyfork.org/scripts/519037/Nexus%20No%20Wait%20%2B%2B.user.js' - try { - const encodedPathname = inputU.pathname; - const lastSlashIndex = encodedPathname.lastIndexOf("/"); - const basePath = encodedPathname.substring(0, lastSlashIndex); - const fileName = encodedPathname.substring(lastSlashIndex + 1); - const reEncodedFileName = encodeURIComponent(decodeURI(fileName)); - if (reEncodedFileName !== fileName) { - const reEncodedPathName = `${basePath}/${reEncodedFileName}`; - const reEncodedUrl = `${inputU.origin}${reEncodedPathName}${inputU.search}${inputU.hash}`; - extraCandidateUrls.add(reEncodedUrl); - } - } catch (e) { - // can skip if it cannot be converted using decodeURI - console.warn(e); // just a warning for debug purpose. - } - } - - return [...extraCandidateUrls]; -}; - const fetchScriptBody = async (url: string, { onProgress }: { [key: string]: any }) => { let origin; try { @@ -702,44 +660,24 @@ function App() { const targetUrlHref = useMemo(() => { if (!hasValidSourceParam) { - /** - * 逻辑说明: - * 在 chrome.declarativeNetRequest 规则中,我们使用 `<,\1,>` 作为占位符引导 API 进行参数填充。 - * 由于不同浏览器版本或配置对 URL 参数的自动编码(Auto-encoding)策略不一致, - * 我们通过检测该占位符的“被编码状态”来逆推浏览器采用了哪种编码方式。 - */ - let m; let url; try { - // 场景 1:URL 完全未编码。直接匹配原始特征符号 "<", ">" 和 "," - if ((m = /\burl=(<,.+,>)(&|$)/.exec(location.search)?.[1])) { - url = m; // 未被编码,取原始值。 - } - // 场景 2:URL 经过了部分编码(类似 encodeURI)。逗号 "," 未被编码,但尖括号被转义为 %3C, %3E - else if ((m = /\burl=(%3C,.+,%3E)(&|$)/.exec(location.search)?.[1])) { - url = decodeURI(m); - } - // 场景 3:URL 经过了完全编码(类似 encodeURIComponent)。逗号也被转义为 %2C - else if ((m = /\burl=(%3C%2C.+%2C%3E)(&|$)/.exec(location.search)?.[1])) { - url = decodeURIComponent(m); - } + // 取url=之后的所有内容 + url = location.search.match(/\?url=([^&]+)/)?.[1] || ""; } catch { // ignored } - // 如果正则匹配/标准解码失败,回退到标准的 searchParams 获取方式 (浏览器会自行理解和解码不规范的编码) - if (!url) url = searchParams.get("url") || ""; // fallback - // 移除人工注入的特征锚点 <, ,>,提取真实的 URL 内容 - url = url.replace(/^<,(.+),>$/, "$1"); // 去掉 <, ,> - if (url) { - try { - const urlObject = new URL(url); - // 验证解析后的 URL 是否具备核心要素,确保安全性与合法性 - if (urlObject.protocol && urlObject.hostname && urlObject.pathname) { - return url; - } - } catch { - // ignored + if (!url) { + return ""; + } + try { + const urlObject = new URL(url); + // 验证解析后的 URL 是否具备核心要素,确保安全性与合法性 + if (urlObject.protocol && urlObject.hostname && urlObject.pathname) { + return url; } + } catch { + // ignored } } return ""; @@ -751,39 +689,21 @@ function App() { errorStatusText: "", }); - const loadURLAsync = async (candidateUrls: string[]) => { - // 1. 定义获取单个脚本的内部逻辑,负责处理进度条与单次错误 - const fetchValidScript = async () => { - let firstError: unknown; - for (const url of candidateUrls) { - try { - const result = await fetchScriptBody(url, { - onProgress: (info: { receivedLength: number }) => { - setFetchingState((prev) => ({ - ...prev, - loadingStatusText: t("downloading_status_text", { bytes: formatBytes(info.receivedLength) }), - })); - }, - }); - if (result.code && result.metadata) { - return { result, url }; // 找到有效的立即返回 - } - } catch (e) { - if (!firstError) firstError = e; - } - } - // 如果循环结束都没成功,抛出第一个捕获到的错误或预设错误 - throw firstError || new Error(t("install_page_load_failed")); - }; - + const loadURLAsync = async (urlHref: string) => { try { // 2. 执行获取 - const { result, url } = await fetchValidScript(); - const { code, metadata } = result; + const { code, metadata } = await fetchScriptBody(urlHref, { + onProgress: (info: { receivedLength: number }) => { + setFetchingState((prev) => ({ + ...prev, + loadingStatusText: t("downloading_status_text", { bytes: `${formatBytes(info.receivedLength)}` }), + })); + }, + }); // 3. 处理数据与缓存 const uuid = uuidv4(); - const scriptData = [false, createScriptInfo(uuid, code, url, "user", metadata)]; + const scriptData = [false, createScriptInfo(uuid, code, urlHref, "user", metadata)]; await cacheInstance.set(`${CACHE_KEY_SCRIPT_INFO}${uuid}`, scriptData); @@ -806,19 +726,10 @@ function App() { } }; - const handleUrlChangeAndFetch = (targetUrlHref: string) => { - setFetchingState((prev) => ({ - ...prev, - loadingStatusText: t("install_page_please_wait"), - })); - const candidateUrls = getCandidateUrls(targetUrlHref); - loadURLAsync(candidateUrls); - }; - // 有 url 的话下载内容 useEffect(() => { - if (targetUrlHref) handleUrlChangeAndFetch(targetUrlHref); - // eslint-disable-next-line react-hooks/exhaustive-deps + if (!targetUrlHref) return; + loadURLAsync(targetUrlHref); }, [targetUrlHref]); if (!hasValidSourceParam) { diff --git a/src/pkg/utils/url-utils.test.ts b/src/pkg/utils/url-utils.test.ts index 2ede754a1..d2e7c6e76 100644 --- a/src/pkg/utils/url-utils.test.ts +++ b/src/pkg/utils/url-utils.test.ts @@ -1,236 +1,5 @@ import { describe, it, expect } from "vitest"; -import { toEncodedURL, prettyUrl } from "./url-utils"; // Update with your actual file path - -describe.concurrent("toEncodedURL() Comprehensive Suite", () => { - describe.concurrent("Core Functionality & Sanitization", () => { - it.concurrent("should handle standard URLs without changes", () => { - const input = "https://example.com/path/to/page?query=1"; - expect(toEncodedURL(input)).toBe("https://example.com/path/to/page?query=1"); - }); - - it.concurrent("should normalize backslashes to forward slashes", () => { - const input = "https:\\\\example.com\\path\\file.txt"; - // URL constructor normalizes host and path separators - expect(toEncodedURL(input)).toBe("https://example.com/path/file.txt"); - }); - - it.concurrent("should preserve non-standard ports", () => { - const input = "https://localhost:8080/api/v1"; - expect(toEncodedURL(input)).toBe("https://localhost:8080/api/v1"); - }); - - it.concurrent("should handle IPv6 addresses with ports", () => { - const input = "http://[2001:db8::1]:8080/path"; - expect(toEncodedURL(input)).toBe("http://[2001:db8::1]:8080/path"); - }); - }); - - describe.concurrent("Punycode & International Domains (IDN)", () => { - it.concurrent("should convert CJK domains to Punycode", () => { - // "点看" (click/see) - const input = "https://点看.com/path"; - const result = toEncodedURL(input); - expect(result).toBe("https://xn--3pxu8k.com/path"); - }); - - it.concurrent("should handle German Umlauts in domains", () => { - const input = "https://müller.de"; - // muller.de -> xn--mller-kva.de - expect(toEncodedURL(input)).toContain("xn--mller-kva.de"); - }); - - it.concurrent("should handle complex CJK domains with ports", () => { - const input = "https://测试.机构:9000/home"; - const result = toEncodedURL(input); - expect(result).toBe("https://xn--0zwm56d.xn--nqv7f:9000/home"); - }); - }); - - describe.concurrent("International Character Encoding (Path vs Query)", () => { - it.concurrent("should encode CJK characters in path and query", () => { - const input = "https://example.com/测试?q=你好"; - const result = toEncodedURL(input); - // Path: /%E6%B5%8B%E8%AF%95 Query: q=%E4%BD%A0%E5%A5%BD - expect(result).toBe("https://example.com/%E6%B5%8B%E8%AF%95?q=%E4%BD%A0%E5%A5%BD"); - }); - - it.concurrent("should handle Latin accents and Foreign scripts", () => { - // French "crème", Russian "москва", Arabic "مرحبا" - const input = "https://example.com/crème/москва?lang=مرحبا"; - const result = toEncodedURL(input); - expect(result).toContain("%C3%A8me"); // crème - expect(result).toContain("%D0%BC%D0%BE%D1%81%D0%BA%D0%B2%D0%B0"); // москва - expect(result).toContain("%D9%85%D8%B1%D8%AD%D8%A8%D8%A7"); // مرحبا - }); - - it.concurrent("should correctly encode Emojis and ZWJ sequences", () => { - // 🚀 and 👨‍👩‍👧 (Family) - const input = "https://example.com/🚀?user=👨‍👩‍👧"; - const result = toEncodedURL(input); - expect(result).toContain("%F0%9F%9A%80"); // 🚀 - expect(decodeURIComponent(result)).toContain("👨‍👩‍👧"); - }); - }); - - describe.concurrent("Spacing & Special Whitespace", () => { - it.concurrent("should encode standard spaces as %20", () => { - const input = "https://example.com/path with space?name=john doe"; - expect(toEncodedURL(input)).toBe("https://example.com/path%20with%20space?name=john%20doe"); - }); - - it.concurrent("should handle CJK Ideographic Spaces (U+3000)", () => { - const input = "https://example.com/CJK Space"; - expect(toEncodedURL(input)).toContain("%E3%80%80"); - }); - - it.concurrent("should handle non-breaking spaces (U+00A0)", () => { - const input = "https://example.com/non\u00A0breaking"; - expect(toEncodedURL(input)).toContain("%C2%A0"); - }); - }); - - describe.concurrent("Encoding Resilience (Double-Encoding Prevention)", () => { - it.concurrent("should not double-encode already encoded Latin/CJK components", () => { - // %20 is already encoded, but 'test' is not - const input = "https://example.com/already%20encoded/测试"; - const result = toEncodedURL(input); - expect(result).toBe("https://example.com/already%20encoded/%E6%B5%8B%E8%AF%95"); - }); - - it.concurrent("should fix partially malformed encoded strings", () => { - // The % at the end is invalid; the function should catch the error and encode it.concurrent - const input = "https://example.com/search?q=100%"; - expect(toEncodedURL(input)).toBe("https://example.com/search?q=100%25"); - }); - }); - - describe.concurrent("Edge Cases", () => { - it.concurrent("should handle URL fragments (#) correctly", () => { - const input = "https://example.com/page?query=1#section-1"; - expect(toEncodedURL(input)).toBe("https://example.com/page?query=1#section-1"); - }); - - it.concurrent("should throw if the input is not a valid URL format", () => { - const input = "this-is-not-a-url"; - expect(() => toEncodedURL(input)).toThrow(); - }); - }); - - describe.concurrent("WHATWG URL Normalization Edge Cases", () => { - it.concurrent("should remove default port 80 for http", () => { - const input = "http://example.com:80/path"; - expect(toEncodedURL(input)).toBe("http://example.com/path"); - }); - - it.concurrent("should remove default port 443 for https", () => { - const input = "https://example.com:443/path"; - expect(toEncodedURL(input)).toBe("https://example.com/path"); - }); - - it.concurrent("should lowercase hostnames", () => { - const input = "https://EXAMPLE.COM/Path"; - expect(toEncodedURL(input)).toContain("example.com"); - }); - - it.concurrent("should resolve dot segments", () => { - const input = "https://example.com/a/b/../c/./d"; - expect(toEncodedURL(input)).toBe("https://example.com/a/c/d"); - }); - - it.concurrent("should auto-add trailing slash for bare origin", () => { - const input = "https://example.com"; - expect(toEncodedURL(input)).toBe("https://example.com/"); - }); - }); - describe.concurrent("Username / Password Handling", () => { - it.concurrent("should preserve username and password", () => { - const input = "https://user:pass@example.com/path"; - expect(toEncodedURL(input)).toBe("https://user:pass@example.com/path"); - }); - - it.concurrent("should encode special chars in username", () => { - const input = "https://us er:p@ss@example.com/"; - const result = toEncodedURL(input); - expect(result).toContain("us%20er"); - }); - }); - - describe.concurrent("Encoded Delimiters", () => { - it.concurrent("should preserve encoded slash in path", () => { - const input = "https://example.com/a%2Fb/c"; - expect(toEncodedURL(input)).toBe("https://example.com/a%2Fb/c"); - }); - - it.concurrent("should preserve encoded question mark in path", () => { - const input = "https://example.com/a%3Fb"; - expect(toEncodedURL(input)).toBe("https://example.com/a%3Fb"); - }); - - it.concurrent("should preserve encoded hash in path", () => { - const input = "https://example.com/a%23b"; - expect(toEncodedURL(input)).toBe("https://example.com/a%23b"); - }); - }); - - describe.concurrent("Duplicate Query Keys", () => { - it.concurrent("should preserve duplicate keys order", () => { - const input = "https://example.com?a=1&a=2&a=3"; - expect(toEncodedURL(input)).toBe("https://example.com/?a=1&a=2&a=3"); - }); - }); - - describe.concurrent("Empty Query Edge Cases", () => { - it.concurrent("should handle empty query key", () => { - const input = "https://example.com/?=value"; - expect(toEncodedURL(input)).toBe("https://example.com/?=value"); - }); - - it.concurrent("should handle empty value", () => { - const input = "https://example.com/?key="; - expect(toEncodedURL(input)).toBe("https://example.com/?key="); - }); - - it.concurrent("should handle bare question mark", () => { - const input = "https://example.com/?"; - expect(toEncodedURL(input)).toBe("https://example.com/?"); - }); - }); - - describe.concurrent("Fragment Encoding", () => { - it.concurrent("should encode unicode in hash", () => { - const input = "https://example.com/#测试"; - const result = toEncodedURL(input); - expect(result).toContain("#%E6%B5%8B%E8%AF%95"); - }); - - it.concurrent("should preserve already encoded hash", () => { - const input = "https://example.com/#%E6%B5%8B"; - expect(toEncodedURL(input)).toBe("https://example.com/#%E6%B5%8B"); - }); - }); - - describe.concurrent("Idempotence Guarantee", () => { - it.concurrent("should return identical result when run twice", () => { - const input = "https://example.com/测试?q=hello world#片段"; - const once = toEncodedURL(input); - const twice = toEncodedURL(once); - expect(twice).toBe(once); - }); - }); - - describe.concurrent("Invalid Percent Encodings", () => { - it.concurrent("should encode stray percent", () => { - const input = "https://example.com/100%complete"; - expect(toEncodedURL(input)).toContain("100%25complete"); - }); - - it.concurrent("should fix broken percent sequence", () => { - const input = "https://example.com/%E0%A4%A"; - const result = toEncodedURL(input); - expect(result).toContain("%25E0"); // or properly re-encoded - }); - }); -}); +import { prettyUrl } from "./url-utils"; // Update with your actual file path describe.concurrent("prettyUrl", () => { describe.concurrent("Domain / Punycode Handling", () => { diff --git a/src/pkg/utils/url-utils.ts b/src/pkg/utils/url-utils.ts index 66e247feb..6c164c4e8 100644 --- a/src/pkg/utils/url-utils.ts +++ b/src/pkg/utils/url-utils.ts @@ -1,85 +1,5 @@ import { decode as punycodeDecode } from "punycode"; -const urlSplit = (url: string) => { - let s = url.split(/(:?\/{2,3}|:?\\{2,3}|[\\/?#])/g); - const i = s.indexOf("?"); - if (i > 0 && s[i + 1]) { - const search = s[i + 1]; - s = [...s.slice(0, i), "?", ...search.split(/([&=])/g), ...s.slice(i + 2)]; - } - return s; -}; - -export const toEncodedURL = (inputUrl: string) => { - const STATE = { - INITIAL: 0, - PATHNAME: 1, - SEARCH: 2, - HASH: 3, - } as const; - - type STATE = ValueOf; - // 1. let URL sanitize the input - // 2. Do manual encoding if URL did not encoded correctly. - const sanitizedInput = new URL(inputUrl); - const originalSplit = urlSplit(inputUrl); - const newSplit = urlSplit(sanitizedInput.href); - let state = 0; - let pathStartFrom = +Infinity; - try { - if (originalSplit.length !== newSplit.length) { - // https://example.com?a=1&a=2&a=3 -> https://example.com/?a=1&a=2&a=3 - throw new Error("mismatch"); - } - const finalSanitizedUrl = newSplit - .map((x, i) => { - if ((i & 1) === 1) if (newSplit[i] !== originalSplit[i]) throw new Error("mismatch"); - if (i < pathStartFrom) { - if ((i & 1) === 1 && (x.endsWith("//") || x.endsWith("\\\\"))) pathStartFrom = i + 2; - return x; - } - if ((i & 1) === 1) { - if (state === STATE.INITIAL && x === "?") state = STATE.SEARCH; - else if (state === STATE.INITIAL && x === "#") state = STATE.HASH; - else if (state === STATE.SEARCH && x === "#") state = STATE.HASH; - return x; - } - if (x.includes("%")) { - if (state === STATE.SEARCH) { - try { - decodeURIComponent(x); - return x; - } catch { - //ignored - } - } else { - try { - decodeURI(x); - return x; - } catch { - //ignored - } - } - } - const ori = originalSplit[i]; - /* - encodeURI allows: - [A-Za-z0-9\-_.!~*'();,/?:@&=+$#] - encodeURIComponent allows: - [A-Za-z0-9\-_.!~*'()] - */ - // const testRe = pathEnded ? /^[%\w\\.!~*'()-]+$/ : /^[%\w\\.!~*'();,/?:@&=+$#-]+$/; - // const isEncoded = testRe.test(ori); - // if (isEncoded) return ori; - return state === STATE.SEARCH ? encodeURIComponent(ori) : encodeURI(ori); - }) - .join(""); - return finalSanitizedUrl; - } catch { - return sanitizedInput.href; - } -}; - function domainPunycodeDecode(s: string): string { if (!s.startsWith("xn--")) return s;