Skip to content

Commit 9c122ab

Browse files
committed
防止脚本安装链结因脚本名字改了而被误判为安装而非更新
1 parent d3c6a85 commit 9c122ab

7 files changed

Lines changed: 290 additions & 12 deletions

File tree

src/app/repo/scripts.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { describe, it, expect, beforeEach } from "vitest";
2+
import {
3+
ScriptDAO,
4+
type Script,
5+
SCRIPT_TYPE_NORMAL,
6+
SCRIPT_STATUS_ENABLE,
7+
SCRIPT_RUN_STATUS_COMPLETE,
8+
} from "./scripts";
9+
10+
const baseMeta = {
11+
name: ["测试脚本"],
12+
namespace: ["test-namespace"],
13+
version: ["1.0.0"],
14+
author: ["测试作者"],
15+
copyright: ["(c) 测试"],
16+
grant: ["GM_xmlhttpRequest"],
17+
match: ["https://example.com/*"],
18+
license: ["MIT"],
19+
};
20+
21+
const makeBaseScript = (overrides: Partial<Script>): Script => ({
22+
uuid: "uuid-base",
23+
name: "测试脚本",
24+
namespace: "test-namespace",
25+
author: "测试作者",
26+
type: SCRIPT_TYPE_NORMAL,
27+
status: SCRIPT_STATUS_ENABLE,
28+
sort: 0,
29+
runStatus: SCRIPT_RUN_STATUS_COMPLETE,
30+
createtime: Date.now(),
31+
updatetime: Date.now(),
32+
checktime: Date.now(),
33+
origin: "https://example.com/script.user.js",
34+
metadata: { ...baseMeta },
35+
...overrides,
36+
});
37+
38+
describe("ScriptDAO.searchExistingScript", () => {
39+
let dao: ScriptDAO;
40+
41+
beforeEach(() => {
42+
dao = new ScriptDAO();
43+
});
44+
45+
it("应能在 scriptcat 场景下匹配到已存在脚本(忽略脚本名差异并校验作者/授权/匹配等信息)", async () => {
46+
const existing = makeBaseScript({
47+
uuid: "sc-1",
48+
origin: "https://scriptcat.org/scripts/code/1234/old-name.js",
49+
});
50+
await dao.save(existing);
51+
52+
const target: Script = makeBaseScript({
53+
uuid: "target-1",
54+
origin: "https://scriptcat.org/scripts/code/1234/new-name.js",
55+
metadata: {
56+
...baseMeta,
57+
// 无 updateurl/downloadurl => 走 scriptcat 分支
58+
} as any,
59+
});
60+
61+
const found = await dao.searchExistingScript(target);
62+
expect(found[0]?.uuid).toBe("sc-1");
63+
});
64+
65+
it("应能在 greasyfork 场景下匹配(忽略文件名差异,基于 id 与 update/download URL)", async () => {
66+
const existing = makeBaseScript({
67+
uuid: "gf-1",
68+
origin: "https://update.greasyfork.org/scripts/1234/old.meta.js",
69+
metadata: {
70+
...baseMeta,
71+
updateurl: ["https://update.greasyfork.org/scripts/1234/old.meta.js"],
72+
downloadurl: ["https://update.greasyfork.org/scripts/1234/old.user.js"],
73+
} as any,
74+
});
75+
await dao.save(existing);
76+
77+
const target: Script = makeBaseScript({
78+
uuid: "target-2",
79+
origin: "https://update.greasyfork.org/scripts/1234/new-name.meta.js",
80+
metadata: {
81+
...baseMeta,
82+
updateurl: ["https://update.greasyfork.org/scripts/1234/new-name.meta.js"],
83+
downloadurl: ["https://update.greasyfork.org/scripts/1234/new-name.user.js"],
84+
} as any,
85+
});
86+
87+
const found = await dao.searchExistingScript(target);
88+
expect(found[0]?.uuid).toBe("gf-1");
89+
});
90+
91+
it("只有 updateurl 也可以匹配 greasyfork 脚本", async () => {
92+
const existing = makeBaseScript({
93+
uuid: "gf-2",
94+
origin: "https://update.greasyfork.org/scripts/5678/keep.meta.js",
95+
metadata: {
96+
...baseMeta,
97+
updateurl: ["https://update.greasyfork.org/scripts/5678/keep.meta.js"],
98+
} as any,
99+
});
100+
await dao.save(existing);
101+
102+
const target: Script = makeBaseScript({
103+
uuid: "target-3",
104+
origin: "https://update.greasyfork.org/scripts/5678/changed-name.meta.js",
105+
metadata: {
106+
...baseMeta,
107+
updateurl: ["https://update.greasyfork.org/scripts/5678/changed-name.meta.js"],
108+
} as any,
109+
});
110+
111+
const found = await dao.searchExistingScript(target);
112+
expect(found[0]?.uuid).toBe("gf-2");
113+
});
114+
115+
it("当元数据关键信息不一致(如 grant)时不应匹配", async () => {
116+
const existing = makeBaseScript({
117+
uuid: "mismatch-1",
118+
origin: "https://scriptcat.org/scripts/code/42/old.js",
119+
});
120+
await dao.save(existing);
121+
122+
const target: Script = makeBaseScript({
123+
uuid: "target-4",
124+
origin: "https://scriptcat.org/scripts/code/42/new.js",
125+
metadata: {
126+
...baseMeta,
127+
grant: ["none"], // 与 existing 不同
128+
} as any,
129+
});
130+
131+
const found = await dao.searchExistingScript(target);
132+
expect(found[0]).toBeUndefined();
133+
});
134+
135+
it("不同域名/来源不应匹配", async () => {
136+
const existing = makeBaseScript({
137+
uuid: "domain-1",
138+
origin: "https://scriptcat.org/scripts/code/999/old.js",
139+
});
140+
await dao.save(existing);
141+
142+
const target: Script = makeBaseScript({
143+
uuid: "target-5",
144+
origin: "https://update.greasyfork.org/scripts/999/new.meta.js",
145+
metadata: {
146+
...baseMeta,
147+
updateurl: ["https://update.greasyfork.org/scripts/999/new.meta.js"],
148+
} as any,
149+
});
150+
151+
const found = await dao.searchExistingScript(target);
152+
expect(found[0]).toBeUndefined();
153+
});
154+
});

src/app/repo/scripts.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ export class ScriptDAO extends Repo<Script> {
134134
});
135135
}
136136

137-
public findByNameAndNamespace(name: string, namespace?: string) {
137+
public findByNameAndNamespace(name: string, namespace: string) {
138138
return this.findOne((key, value) => {
139139
return value.name === name && (!namespace || value.namespace === namespace);
140140
});
@@ -151,6 +151,99 @@ export class ScriptDAO extends Repo<Script> {
151151
return value.origin === origin && value.subscribeUrl === suburl;
152152
});
153153
}
154+
155+
public async searchExistingScript(targetScript: Script, toCheckScriptInfoEqual: boolean = true): Promise<Script[]> {
156+
const removeScriptNameFromURL = (url: string) => {
157+
// https://scriptcat.org/scripts/code/{id}/{scriptname}.user.js (单匹配)
158+
if (url.startsWith("https://scriptcat.org/scripts/code/") && url.endsWith(".js")) {
159+
const idx1 = url.indexOf("/", "https://scriptcat.org/scripts/code/".length);
160+
const idx2 = url.indexOf("/", idx1 + 1);
161+
if (idx1 > 0 && idx2 < 0) {
162+
const idx3 = url.indexOf(".", idx1 + 1);
163+
return url.substring(0, idx1 + 1) + "*" + url.substring(idx3);
164+
}
165+
}
166+
// https://update.greasyfork.org/scripts/{id}/{scriptname}.user.js (单匹配)
167+
if (url.startsWith("https://update.greasyfork.org/scripts/") && url.endsWith(".js")) {
168+
const idx1 = url.indexOf("/", "https://update.greasyfork.org/scripts/".length);
169+
const idx2 = url.indexOf("/", idx1 + 1);
170+
if (idx1 > 0 && idx2 < 0) {
171+
const idx3 = url.indexOf(".", idx1 + 1);
172+
return url.substring(0, idx1 + 1) + "*" + url.substring(idx3);
173+
}
174+
}
175+
// https://openuserjs.org/install/{username}/{scriptname}.user.js (复数匹配)
176+
if (url.startsWith("https://openuserjs.org/install/") && url.endsWith(".js")) {
177+
const idx1 = url.indexOf("/", "https://openuserjs.org/install/".length);
178+
const idx2 = url.indexOf("/", idx1 + 1);
179+
if (idx1 > 0 && idx2 < 0) {
180+
const idx3 = url.indexOf(".", idx1 + 1);
181+
return url.substring(0, idx1 + 1) + "*" + url.substring(idx3);
182+
}
183+
}
184+
return url;
185+
};
186+
const valEqual = (val1: any, val2: any) => {
187+
if (val1 && val2 && Array.isArray(val1) && Array.isArray(val2)) {
188+
if (val1.length !== val2.length) return false;
189+
if (val1.length < 2) {
190+
return val1[0] === val2[0];
191+
}
192+
// 無視次序
193+
const s = new Set([...val1, ...val2]);
194+
if (s.size !== val1.length) return false;
195+
return true;
196+
}
197+
return val1 === val2;
198+
};
199+
const isScriptInfoEqual = (script1: Script, script2: Script) => {
200+
// @author, @copyright, @license 應該不會改
201+
if (!valEqual(script1.metadata.author, script2.metadata.author)) return false;
202+
if (!valEqual(script1.metadata.copyright, script2.metadata.copyright)) return false;
203+
if (!valEqual(script1.metadata.license, script2.metadata.license)) return false;
204+
// @grant, @connect 應該不會改
205+
if (!valEqual(script1.metadata.grant, script2.metadata.grant)) return false;
206+
if (!valEqual(script1.metadata.connect, script2.metadata.connect)) return false;
207+
// @match @include 應該不會改
208+
if (!valEqual(script1.metadata.match, script2.metadata.match)) return false;
209+
if (!valEqual(script1.metadata.include, script2.metadata.include)) return false;
210+
return true;
211+
};
212+
213+
const { metadata, origin } = targetScript;
214+
215+
if (origin && !metadata?.updateurl?.[0] && !metadata?.downloadurl?.[0]) {
216+
// scriptcat
217+
const targetOrigin = removeScriptNameFromURL(origin);
218+
return this.find((key, entry) => {
219+
if (!entry.origin) return false;
220+
const entryOrigin = removeScriptNameFromURL(entry.origin);
221+
if (targetOrigin !== entryOrigin) return false;
222+
if (toCheckScriptInfoEqual && !isScriptInfoEqual(targetScript, entry)) return false;
223+
return true;
224+
});
225+
} else if (origin && (metadata?.updateurl?.[0] || metadata?.downloadurl?.[0])) {
226+
// greasyfork
227+
228+
const targetOrigin = removeScriptNameFromURL(origin);
229+
const targetUpdateURL = removeScriptNameFromURL(metadata?.updateurl?.[0] || "");
230+
const targetDownloadURL = removeScriptNameFromURL(metadata?.downloadurl?.[0] || "");
231+
return this.find((key, entry) => {
232+
if (!entry.origin) return false;
233+
const entryOrigin = removeScriptNameFromURL(entry.origin);
234+
if (targetOrigin !== entryOrigin) return false;
235+
236+
const entryUpdateURL = removeScriptNameFromURL(entry.metadata?.updateurl?.[0] || "");
237+
const entryDownloadURL = removeScriptNameFromURL(entry.metadata?.downloadurl?.[0] || "");
238+
239+
if (targetUpdateURL !== entryUpdateURL || targetDownloadURL !== entryDownloadURL) return false;
240+
if (toCheckScriptInfoEqual && !isScriptInfoEqual(targetScript, entry)) return false;
241+
return true;
242+
});
243+
} else {
244+
return [];
245+
}
246+
}
154247
}
155248

156249
// 为了防止脚本代码数据量过大,单独存储脚本代码

src/app/service/service_worker/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export class ScriptClient extends Client {
4747

4848
// 获取安装信息
4949
getInstallInfo(uuid: string) {
50-
return this.do<[boolean, ScriptInfo]>("getInstallInfo", uuid);
50+
return this.do<[boolean, ScriptInfo, { byWebRequest?: boolean }]>("getInstallInfo", uuid);
5151
}
5252

5353
install(script: Script, code: string, upsertBy: InstallSource = "user"): Promise<{ update: boolean }> {

src/app/service/service_worker/script.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export class ScriptService {
107107
// 读取脚本url内容, 进行安装
108108
const logger = this.logger.with({ url: targetUrl });
109109
logger.debug("install script");
110-
this.openInstallPageByUrl(targetUrl, "user")
110+
this.openInstallPageByUrl(targetUrl, { source: "user", byWebRequest: true })
111111
.catch((e) => {
112112
logger.error("install script error", Logger.E(e));
113113
// 不再重定向当前url
@@ -205,10 +205,13 @@ export class ScriptService {
205205
);
206206
}
207207

208-
public async openInstallPageByUrl(url: string, source: InstallSource): Promise<{ success: boolean; msg: string }> {
208+
public async openInstallPageByUrl(
209+
url: string,
210+
options: { source: InstallSource; byWebRequest?: boolean }
211+
): Promise<{ success: boolean; msg: string }> {
209212
const uuid = uuidv4();
210213
try {
211-
await this.openUpdateOrInstallPage(uuid, url, source, false);
214+
await this.openUpdateOrInstallPage(uuid, url, options, false);
212215
timeoutExecution(
213216
`${cIdKey}_cleanup_${uuid}`,
214217
() => {
@@ -711,7 +714,14 @@ export class ScriptService {
711714
return script;
712715
}
713716

714-
async openUpdateOrInstallPage(uuid: string, url: string, upsertBy: InstallSource, update: boolean, logger?: Logger) {
717+
async openUpdateOrInstallPage(
718+
uuid: string,
719+
url: string,
720+
options: { source: InstallSource; byWebRequest?: boolean },
721+
update: boolean,
722+
logger?: Logger
723+
) {
724+
const upsertBy = options.source;
715725
const code = await fetchScriptBody(url);
716726
if (update && (await this.systemConfig.getSilenceUpdateScript())) {
717727
try {
@@ -735,7 +745,7 @@ export class ScriptService {
735745
if (!metadata) {
736746
throw new Error("parse script info failed");
737747
}
738-
const si = [update, createScriptInfo(uuid, code, url, upsertBy, metadata)];
748+
const si = [update, createScriptInfo(uuid, code, url, upsertBy, metadata), options];
739749
await cacheInstance.set(`${CACHE_KEY_SCRIPT_INFO}${uuid}`, si);
740750
return 1;
741751
}
@@ -751,7 +761,7 @@ export class ScriptService {
751761
});
752762
const url = downloadUrl || checkUpdateUrl!;
753763
try {
754-
const ret = await this.openUpdateOrInstallPage(uuid, url, source, true, logger);
764+
const ret = await this.openUpdateOrInstallPage(uuid, url, { source }, true, logger);
755765
if (ret === 2) return; // slience update
756766
// 打开安装页面
757767
openInCurrentTab(`/src/install.html?uuid=${uuid}`);
@@ -1141,7 +1151,7 @@ export class ScriptService {
11411151
}
11421152

11431153
importByUrl(url: string) {
1144-
return this.openInstallPageByUrl(url, "user");
1154+
return this.openInstallPageByUrl(url, { source: "user" });
11451155
}
11461156

11471157
setCheckUpdateUrl({

src/app/service/service_worker/subscribe.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ export class SubscribeService {
231231
if (true === (await this.trySilenceUpdate(code, url))) {
232232
// slience update
233233
} else {
234-
const si = [false, createScriptInfo(uuid, code, url, source, metadata)];
234+
const si = [false, createScriptInfo(uuid, code, url, source, metadata), {}];
235235
await cacheInstance.set(`${CACHE_KEY_SCRIPT_INFO}${uuid}`, si);
236236
chrome.tabs.create({
237237
url: `/src/install.html?uuid=${uuid}`,

src/pages/install/App.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,12 @@ function App() {
8888
const uuid = locationUrl.searchParams.get("uuid");
8989
let info: ScriptInfo | undefined;
9090
let isKnownUpdate: boolean = false;
91+
let paramOptions = {};
9192
if (uuid) {
9293
const cachedInfo = await scriptClient.getInstallInfo(uuid);
9394
if (cachedInfo?.[0]) isKnownUpdate = true;
9495
info = cachedInfo?.[1] || undefined;
96+
paramOptions = cachedInfo?.[2] || {};
9597
if (!info) {
9698
throw new Error("fetch script info failed");
9799
}
@@ -145,7 +147,7 @@ function App() {
145147
diffCode = prepare.oldSubscribe?.code;
146148
} else {
147149
const knownUUID = isKnownUpdate ? info.uuid : undefined;
148-
prepare = await prepareScriptByCode(code, url, knownUUID);
150+
prepare = await prepareScriptByCode(code, url, knownUUID, false, undefined, paramOptions);
149151
action = prepare.script;
150152
if (prepare.oldScript) {
151153
oldVersion = prepare.oldScript!.metadata!.version![0] || "";

0 commit comments

Comments
 (0)