deploy: sync server CloudSearch 0.5.6

This commit is contained in:
2026-06-25 02:08:39 +08:00
parent 75f5d26964
commit ce3f95b8b9
53 changed files with 2740 additions and 116 deletions
+1 -1
View File
@@ -1 +1 @@
0.5.5
0.5.6
@@ -0,0 +1,58 @@
# CloudSearch 单容器网盘能力 P0 实现计划
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
**目标:** 将 UC 网盘转存能力和主应用云盘 driver 分发能力集成进 `source_clean`,由现有 `CloudSearch_App` 单容器构建和运行,不新增长期 transfer 容器。
**架构:** 在 Node/TypeScript 主应用内新增 `UcDriver` 和轻量 driver factory。`saveFromShare()`、凭据验证、连接测试仍走现有 `cloud_configs``save_records``/api/save` 路径,只扩展 `uc` 类型,不改变前端主流程。
**技术栈:** Node 20 fetch、TypeScript commonjs、Express、better-sqlite3、Vitest、Docker Compose。
---
## 文件结构
- 创建:`src/cloud/drivers/uc-api.ts` — UC API 常量、请求头、URL 解析、任务轮询辅助。
- 创建:`src/cloud/drivers/uc.driver.ts` — UC Cookie 验证、7 步转存、创建新分享,对齐现有 driver 返回结构。
- 创建:`src/cloud/drivers/uc.driver.test.ts` — UC driver 红绿回归测试,mock fetch 验证 API 流程。
- 创建:`src/cloud/driver-factory.ts` — 根据 `cloud_type` 创建 driver,避免 `credential.service.ts``cloud.service.ts` 分散 switch。
- 创建:`src/cloud/driver-factory.test.ts` — 验证 `uc` 使用 `UcDriver`,未知类型返回 `null`
- 修改:`src/cloud/cloud.service.ts` — 保存入口支持 `uc`,复用统一 driver factory。
- 修改:`src/cloud/credential.service.ts` — Cookie 测试和运行前凭据验证支持 `uc`
## 任务 1:写失败测试
- [ ] 编写 `src/cloud/driver-factory.test.ts`:断言 `createCloudDriver(uc, {cookie})` 产物 class 为 `UcDriver``unknown``null`
- [ ] 编写 `src/cloud/drivers/uc.driver.test.ts`:断言 UC URL 解析、Cookie 长度校验、mock fetch 的完整转存链路。
- [ ] 运行:`npm test -- src/cloud/driver-factory.test.ts src/cloud/drivers/uc.driver.test.ts`
- [ ] 预期:失败,原因是 `driver-factory``uc.driver` 不存在。
## 任务 2:实现 UC driver 和 factory
- [ ] 新增 `uc-api.ts``UC_API_BASE=https://pc-api.uc.cn``UC_SHARE_API``getUcHeaders()``parseUcShareUrl()`
- [ ] 新增 `uc.driver.ts`:实现 `validate()``saveFromShare()`,流程为 stoken、detail、save、poll save task、create share、poll share task、set password。
- [ ] 新增 `driver-factory.ts`:支持 `quark``baidu``aliyun``uc`
- [ ] 运行同一组测试,预期通过。
## 任务 3:接入主保存和凭据验证路径
- [ ] 修改 `cloud.service.ts`:用 factory 创建 driver`uc` 调用 `saveFromShare()`,返回字段写入现有 `save_records`
- [ ] 修改 `credential.service.ts``testCloudConnectionWithCookie(uc)``getAndValidateCredential(uc)` 使用 `UcDriver.validate()`
- [ ] 新增或扩展测试覆盖 factory 接入。
- [ ] 运行:`npm test -- src/cloud/driver-factory.test.ts src/cloud/drivers/uc.driver.test.ts`
## 任务 4:构建与单容器部署验证
- [ ] 运行:`npm run build`
- [ ] 运行:`docker compose build app`
- [ ] 运行:`docker compose up -d app`
- [ ] 验证:`docker compose ps` 只有 `app/redis/pansou`,没有 transfer 容器。
- [ ] 验证:`curl -fsS http://127.0.0.1:9527/health`
- [ ] 验证:`curl -fsS http://127.0.0.1:9527/api/admin/cloud-types` 能看到 `uc`
## 验收标准
- `CloudSearch_App` 单容器内的主应用构建包含 `UcDriver`
- `/api/save``target_cloud=uc` 不再返回“暂不支持”。
- 无长期 `cloudsearch-transfer``cloud-auto-save-test` 容器。
- 没有收益上报、签到、独立 transfer 服务集成。
+39 -4
View File
@@ -3,16 +3,51 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="description" content="随便下 | 资源小站,提供网盘资源搜索、热门榜单和免责声明页面。" />
<meta name="robots" content="index,follow" />
<meta property="og:type" content="website" />
<meta property="og:title" content="CloudSearch - 网盘资源搜索" />
<meta property="og:description" content="随便下 | 资源小站,提供网盘资源搜索、热门榜单和免责声明页面。" />
<meta property="og:url" content="https://zy.hk.timxx.cn/" />
<meta property="og:image" content="https://zy.hk.timxx.cn/api/uploads/logo/site_logo.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="CloudSearch - 网盘资源搜索" />
<meta name="twitter:description" content="随便下 | 资源小站,提供网盘资源搜索、热门榜单和免责声明页面。" />
<meta name="twitter:image" content="https://zy.hk.timxx.cn/api/uploads/logo/site_logo.png" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<link rel="canonical" href="https://zy.hk.timxx.cn/" />
<title>CloudSearch - 网盘资源搜索</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script>
(function() {
fetch('/api/site-config').then(function(r){return r.json()}).then(function(cfg){
if(cfg.site_name) document.title = cfg.site_name + ' - 网盘资源搜索';
}).catch(function(){});
function setMeta(selector, attr, value) {
var el = document.querySelector(selector);
if (!el) return;
el.setAttribute(attr, value);
}
function sync(cfg) {
var origin = window.location.origin;
var siteName = cfg.site_name || 'CloudSearch';
var title = siteName + ' - 网盘资源搜索';
var description = cfg.site_disclaimer || '随便下 | 资源小站,提供网盘资源搜索、热门榜单和免责声明页面。';
var image = cfg.site_logo ? new URL(cfg.site_logo, origin).href : origin + '/api/uploads/logo/site_logo.png';
var canonical = origin + '/';
document.title = title;
setMeta('meta[name="description"]', 'content', description);
setMeta('meta[name="robots"]', 'content', 'index,follow');
setMeta('meta[property="og:title"]', 'content', title);
setMeta('meta[property="og:description"]', 'content', description);
setMeta('meta[property="og:url"]', 'content', canonical);
setMeta('meta[property="og:image"]', 'content', image);
setMeta('meta[name="twitter:title"]', 'content', title);
setMeta('meta[name="twitter:description"]', 'content', description);
setMeta('meta[name="twitter:image"]', 'content', image);
setMeta('link[rel="canonical"]', 'href', canonical);
}
sync({});
fetch('/api/site-config').then(function(r){return r.json()}).then(function(cfg){ sync(cfg || {}); }).catch(function(){});
})();
</script>
</head>
@@ -20,4 +55,4 @@
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
</html>
+387
View File
@@ -23,6 +23,7 @@
"@vitejs/plugin-vue": "^5.1.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vitest": "^2.1.9",
"vue-tsc": "^2.1.0"
}
},
@@ -869,6 +870,121 @@
"vue": "^3.2.25"
}
},
"node_modules/@vitest/expect": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
"dev": true,
"dependencies": {
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
"dev": true,
"dependencies": {
"@vitest/spy": "2.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.12"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/pretty-format": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
"dev": true,
"dependencies": {
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
"dev": true,
"dependencies": {
"@vitest/utils": "2.1.9",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
"dev": true,
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"magic-string": "^0.30.12",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
"dev": true,
"dependencies": {
"tinyspy": "^3.0.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
"dev": true,
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"loupe": "^3.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@volar/language-core": {
"version": "2.4.15",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
@@ -1086,6 +1202,15 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"engines": {
"node": ">=12"
}
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
@@ -1121,6 +1246,15 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1141,6 +1275,31 @@
"node": ">=6"
}
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"engines": {
"node": ">= 16"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
@@ -1194,6 +1353,23 @@
"integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
"dev": true
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
@@ -1202,6 +1378,15 @@
"node": ">=0.10.0"
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1294,6 +1479,12 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -1362,6 +1553,15 @@
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@@ -1568,6 +1768,12 @@
"lodash-es": "*"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1623,6 +1829,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
@@ -1698,6 +1910,21 @@
"node": ">=8"
}
},
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1845,6 +2072,12 @@
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1853,6 +2086,18 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -1877,6 +2122,45 @@
"node": ">=8"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
"dev": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
"dev": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
@@ -1960,6 +2244,93 @@
}
}
},
"node_modules/vite-node": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
"dev": true,
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.3.7",
"es-module-lexer": "^1.5.4",
"pathe": "^1.1.2",
"vite": "^5.0.0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitest": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
"dev": true,
"dependencies": {
"@vitest/expect": "2.1.9",
"@vitest/mocker": "2.1.9",
"@vitest/pretty-format": "^2.1.9",
"@vitest/runner": "2.1.9",
"@vitest/snapshot": "2.1.9",
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"debug": "^4.3.7",
"expect-type": "^1.1.0",
"magic-string": "^0.30.12",
"pathe": "^1.1.2",
"std-env": "^3.8.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.1",
"tinypool": "^1.0.1",
"tinyrainbow": "^1.2.0",
"vite": "^5.0.0",
"vite-node": "2.1.9",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "2.1.9",
"@vitest/ui": "2.1.9",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
@@ -2060,6 +2431,22 @@
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+3 -1
View File
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
@@ -24,6 +25,7 @@
"@vitejs/plugin-vue": "^5.1.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vitest": "^2.1.9",
"vue-tsc": "^2.1.0"
}
}
}
@@ -0,0 +1,3 @@
User-agent: *
Allow: /
Sitemap: https://zy.hk.timxx.cn/sitemap.xml
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://zy.hk.timxx.cn/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://zy.hk.timxx.cn/search</loc>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://zy.hk.timxx.cn/disclaimer/</loc>
<changefreq>monthly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://zy.hk.timxx.cn/admin/login</loc>
<changefreq>monthly</changefreq>
<priority>0.2</priority>
</url>
<url>
<loc>https://zy.hk.timxx.cn/user/login</loc>
<changefreq>monthly</changefreq>
<priority>0.2</priority>
</url>
</urlset>
+43
View File
@@ -3,6 +3,49 @@
</template>
<script setup lang="ts">
import { onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { applySeo, resolveSeo } from './seo'
interface SiteConfig {
site_name?: string
site_logo?: string
site_disclaimer?: string
}
const route = useRoute()
let currentSiteName = 'CloudSearch'
let currentSiteLogo = ''
let currentSiteDisclaimer = ''
function refreshSeo() {
const origin = typeof window !== 'undefined' ? window.location.origin : ''
applySeo(resolveSeo(route.path, route.query as Record<string, unknown>, currentSiteName, origin, currentSiteLogo, currentSiteDisclaimer))
}
async function loadSiteConfig() {
try {
const resp = await fetch('/api/site-config')
if (!resp.ok) return
const cfg = (await resp.json()) as SiteConfig
if (cfg.site_name) currentSiteName = cfg.site_name
if (cfg.site_logo) currentSiteLogo = cfg.site_logo
if (cfg.site_disclaimer) currentSiteDisclaimer = cfg.site_disclaimer
} catch {
// keep defaults
}
}
onMounted(async () => {
await loadSiteConfig()
refreshSeo()
})
watch(
() => route.fullPath,
() => refreshSeo(),
{ immediate: true },
)
</script>
<style>
@@ -0,0 +1,42 @@
<template>
<main class="disclaimer-page">
<section class="disclaimer-card">
<h1>免责声明</h1>
<p>CloudSearch 仅聚合和展示公开网络资源线索不存储不上传不分发任何网盘文件内容</p>
<p>搜索结果来自第三方公开页面或接口资源有效性合法性与安全性需由用户自行判断</p>
<p>请勿使用本站搜索保存或传播侵权违法违规内容如权利人认为相关信息侵犯权益请联系站点管理员处理</p>
<router-link class="back-link" to="/">返回首页</router-link>
</section>
</main>
</template>
<style scoped>
.disclaimer-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
background: #f8fafc;
}
.disclaimer-card {
max-width: 760px;
padding: 32px;
border-radius: 18px;
background: #fff;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
color: #334155;
line-height: 1.8;
}
.disclaimer-card h1 {
margin: 0 0 20px;
color: #0f172a;
}
.back-link {
display: inline-flex;
margin-top: 20px;
color: #2563eb;
text-decoration: none;
font-weight: 600;
}
</style>
@@ -1,12 +1,13 @@
<template>
<div class="home-page">
<div class="hero-section">
<h1 class="sr-only">网盘资源搜索</h1>
<template v-if="configLoaded">
<img v-if="siteLogo" :src="siteLogo" :alt="siteName || 'CloudSearch'" class="logo-img" @error="(e: any) => { (e.target as HTMLElement).style.display='none'; siteLogo='' }" />
<div v-else class="logo-text">{{ siteName || 'CloudSearch' }}</div>
</template>
<div class="search-box">
<el-input v-model="query" placeholder="搜索网盘资源..." size="large" clearable @keyup.enter="handleSearch">
<el-input v-model="query" aria-label="搜索网盘资源" name="q" placeholder="搜索网盘资源..." size="large" clearable @keyup.enter="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" size="large" @click="handleSearch" class="search-btn">搜索</el-button>
@@ -27,6 +28,7 @@
<div class="footer-inner">{{ siteDisclaimer }}</div>
<el-button class="footer-btn" size="small" @click="openDisclaimer">免责声明</el-button>
</div>
</div>
</template>
@@ -35,6 +37,7 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Search } from '@element-plus/icons-vue'
import { getCategorizedRankings, getSiteConfig } from '../api'
import { createDisclaimerTarget, createSearchPath } from '../utils/home-helpers'
const router = useRouter()
const query = ref('')
@@ -50,9 +53,9 @@ const QS = ['学而时习之,不亦说乎。', '温故而知新,可以为师
const CS = ['#e74c3c', '#e67e22', '#f39c12', '#27ae60', '#2980b9', '#8e44ad', '#2c3e50', '#16a085', '#d35400', '#c0392b', '#1abc9c', '#3498db', '#9b59b6', '#34495e', '#e91e63', '#009688', '#ff5722', '#795548', '#607d8b', '#673ab7']
const SS = [48,42,38,34,30,28,26,24,22,20,18,17,16,15,14,13,12]
function handleSearch() { const q = query.value.trim(); if(q) router.push('/search?q='+encodeURIComponent(q)) }
function searchTag(t:string) { router.push('/search?q='+encodeURIComponent(t)) }
function openDisclaimer() { window.open('/disclaimer/', '_blank') }
function handleSearch() { const path = createSearchPath(query.value); if(path) router.push(path) }
function searchTag(t:string) { const path = createSearchPath(t); if(path) router.push(path) }
function openDisclaimer() { window.location.assign(createDisclaimerTarget()) }
onMounted(async () => {
currentQuote.value = QS[Math.floor(Math.random()*QS.length)]
@@ -81,6 +84,7 @@ onMounted(async () => {
<style scoped>
.home-page{min-height:100vh;background:#f5f7fa}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.hero-section{text-align:center;padding:56px 24px 36px;background:#f5f7fa}
.logo-text{font-size:48px;font-weight:700;color:#1d2129;margin-bottom:24px}
.logo-img{display:block;max-width:400px;max-height:100px;margin:0 auto 24px}
@@ -0,0 +1,27 @@
<template>
<main class="not-found-page">
<section class="not-found-card">
<p class="eyebrow">404</p>
<h1>页面不存在</h1>
<p class="description">你访问的页面不存在或已被移动</p>
<el-button type="primary" @click="goHome">返回首页</el-button>
</section>
</main>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
function goHome() {
router.push('/')
}
</script>
<style scoped>
.not-found-page{min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f5f7fa;padding:24px}
.not-found-card{width:min(460px,100%);padding:40px 32px;text-align:center;background:#fff;border-radius:18px;box-shadow:0 12px 32px rgba(31,45,61,.08)}
.eyebrow{margin:0 0 8px;color:#409eff;font-size:14px;font-weight:700;letter-spacing:4px}
h1{margin:0 0 12px;font-size:28px;color:#1d2129}
.description{margin:0 0 24px;color:#606266;line-height:1.7}
</style>
+14 -3
View File
@@ -1,6 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router'
const routes = [
export const routes = [
{
path: '/',
name: 'home',
@@ -16,6 +16,12 @@ const routes = [
name: 'result-detail',
component: () => import('./pages/ResultDetail.vue'),
},
{
path: '/disclaimer',
alias: '/disclaimer/',
name: 'disclaimer',
component: () => import('./pages/Disclaimer.vue'),
},
{
path: '/admin/login',
name: 'admin-login',
@@ -71,10 +77,15 @@ const routes = [
path: '/user',
redirect: '/user/login',
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('./pages/NotFound.vue'),
},
]
const router = createRouter({
history: createWebHistory(),
history: typeof window === 'undefined' ? createMemoryHistory() : createWebHistory(),
routes,
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { resolveSeo } from './seo'
describe('resolveSeo', () => {
it('builds home page seo for the site root', () => {
const seo = resolveSeo('/', {}, '随便下 | 资源小站', 'https://zy.hk.timxx.cn')
expect(seo.title).toBe('随便下 | 资源小站 - 网盘资源搜索')
expect(seo.description).toContain('网盘资源搜索')
expect(seo.canonical).toBe('https://zy.hk.timxx.cn/')
expect(seo.robots).toBe('index,follow')
})
it('builds search page seo from the q parameter', () => {
const seo = resolveSeo('/search', { q: '阿凡达' }, '随便下 | 资源小站', 'https://zy.hk.timxx.cn')
expect(seo.title).toBe('阿凡达 - 搜索结果 | 随便下 | 资源小站')
expect(seo.description).toContain('阿凡达')
expect(seo.canonical).toBe('https://zy.hk.timxx.cn/search?q=%E9%98%BF%E5%87%A1%E8%BE%BE')
})
it('marks admin pages as noindex', () => {
const seo = resolveSeo('/admin/login', {}, '随便下 | 资源小站', 'https://zy.hk.timxx.cn')
expect(seo.robots).toBe('noindex,nofollow')
expect(seo.title).toContain('管理后台')
})
})
+125
View File
@@ -0,0 +1,125 @@
export interface SeoState {
title: string;
description: string;
canonical: string;
image: string;
robots: string;
}
function getSearchQuery(query: Record<string, unknown>): string {
const q = query.q;
if (typeof q === 'string') return q.trim();
const input = query.input;
if (typeof input === 'string') return input.trim();
return '';
}
function normalizePath(pathname: string): string {
if (!pathname) return '/';
if (!pathname.startsWith('/')) return `/${pathname}`;
return pathname;
}
function buildCanonical(origin: string, pathname: string, query: Record<string, unknown>): string {
const base = origin ? origin.replace(/\/+$/, '') : '';
const normalizedPath = normalizePath(pathname);
const params = new URLSearchParams();
if (normalizedPath.startsWith('/search')) {
const q = getSearchQuery(query);
if (q) params.set('q', q);
}
const suffix = params.toString() ? `?${params.toString()}` : '';
return `${base}${normalizedPath}${suffix}` || normalizedPath;
}
export function resolveSeo(
pathname: string,
query: Record<string, unknown> = {},
siteName = 'CloudSearch',
origin = '',
siteLogo = '',
siteDisclaimer = '',
): SeoState {
const normalizedPath = normalizePath(pathname);
const q = getSearchQuery(query);
const titleSuffix = `${siteName} - 网盘资源搜索`;
let title = titleSuffix;
let description = `${siteName} 提供网盘资源搜索、热门榜单和内容聚合。`;
let robots = 'index,follow';
if (normalizedPath.startsWith('/search')) {
if (q) {
title = `${q} - 搜索结果 | ${siteName}`;
description = `搜索 ${q} 的网盘资源结果,查看链接、分类和保存记录。`;
} else {
title = `搜索结果 | ${siteName}`;
description = `${siteName} 的网盘资源搜索结果页。`;
}
} else if (normalizedPath.startsWith('/result/')) {
title = `资源详情 | ${siteName}`;
description = `查看资源详情、链接信息和关联内容。`;
} else if (normalizedPath.startsWith('/disclaimer')) {
title = `免责声明 | ${siteName}`;
description = siteDisclaimer || `查看 ${siteName} 的免责声明和使用说明。`;
} else if (normalizedPath.startsWith('/admin')) {
title = `管理后台 | ${siteName}`;
description = `访问 ${siteName} 的管理后台。`;
robots = 'noindex,nofollow';
} else if (normalizedPath.startsWith('/user')) {
title = `用户中心 | ${siteName}`;
description = `访问 ${siteName} 的用户中心。`;
robots = 'noindex,nofollow';
}
const canonical = buildCanonical(origin, normalizedPath, query);
const image = siteLogo
? new URL(siteLogo, origin || 'https://zy.hk.timxx.cn').href
: `${origin ? origin.replace(/\/+$/, '') : ''}/api/uploads/logo/site_logo.png` || '/api/uploads/logo/site_logo.png';
return {
title,
description,
canonical,
image,
robots,
};
}
function setMeta(selector: string, attr: string, value: string): void {
if (typeof document === 'undefined') return;
let el = document.querySelector(selector) as HTMLMetaElement | HTMLLinkElement | null;
if (!el) {
el = document.createElement(selector.startsWith('link') ? 'link' : 'meta');
if (selector.startsWith('meta[name="')) {
const name = selector.match(/meta\[name="([^"]+)"\]/)?.[1];
if (name) (el as HTMLMetaElement).setAttribute('name', name);
}
if (selector.startsWith('meta[property="')) {
const property = selector.match(/meta\[property="([^"]+)"\]/)?.[1];
if (property) (el as HTMLMetaElement).setAttribute('property', property);
}
if (selector.startsWith('link[')) {
const rel = selector.match(/link\[rel="([^"]+)"\]/)?.[1];
if (rel) (el as HTMLLinkElement).setAttribute('rel', rel);
}
document.head.appendChild(el);
}
el.setAttribute(attr, value);
}
export function applySeo(seo: SeoState): void {
if (typeof document === 'undefined') return;
document.title = seo.title;
setMeta('meta[name="description"]', 'content', seo.description);
setMeta('meta[name="robots"]', 'content', seo.robots);
setMeta('meta[property="og:title"]', 'content', seo.title);
setMeta('meta[property="og:description"]', 'content', seo.description);
setMeta('meta[property="og:type"]', 'content', 'website');
setMeta('meta[property="og:url"]', 'content', seo.canonical);
setMeta('meta[property="og:image"]', 'content', seo.image);
setMeta('meta[name="twitter:card"]', 'content', 'summary_large_image');
setMeta('meta[name="twitter:title"]', 'content', seo.title);
setMeta('meta[name="twitter:description"]', 'content', seo.description);
setMeta('meta[name="twitter:image"]', 'content', seo.image);
setMeta('link[rel="canonical"]', 'href', seo.canonical);
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { createDisclaimerTarget, createSearchPath, getHealthDisplay } from './home-helpers';
describe('home helpers', () => {
it('builds an encoded search path from trimmed user input', () => {
expect(createSearchPath(' 周杰伦 演唱会 ')).toBe('/search?q=%E5%91%A8%E6%9D%B0%E4%BC%A6%20%E6%BC%94%E5%94%B1%E4%BC%9A');
expect(createSearchPath(' ')).toBeNull();
});
it('uses same-tab navigation for the full disclaimer page', () => {
expect(createDisclaimerTarget()).toBe('/disclaimer');
});
it('marks optional video parser gaps as a test-environment note, not a core outage', () => {
const display = getHealthDisplay('ok', {
videoParser: 'unreachable',
videoParserRequired: false,
});
expect(display.isCoreHealthy).toBe(true);
expect(display.label).toBe('核心服务正常');
expect(display.note).toContain('视频解析');
expect(display.note).toContain('测试环境');
});
});
@@ -0,0 +1,35 @@
export interface HealthDisplayComponents {
videoParser?: string;
videoParserRequired?: boolean;
}
export interface HealthDisplay {
label: string;
isCoreHealthy: boolean;
note: string;
}
export function createSearchPath(query: string): string | null {
const trimmed = query.trim();
if (!trimmed) return null;
return `/search?q=${encodeURIComponent(trimmed)}`;
}
export function createDisclaimerTarget(path = '/disclaimer'): string {
return path;
}
export function getHealthDisplay(status: string, components: HealthDisplayComponents = {}): HealthDisplay {
const isCoreHealthy = status === 'ok' || (!components.videoParserRequired && status === 'degraded');
const label = isCoreHealthy ? '核心服务正常' : status === 'degraded' ? '部分服务降级' : '服务异常';
const videoParserOptionalGap = !components.videoParserRequired
&& components.videoParser
&& components.videoParser !== 'ok';
return {
label,
isCoreHealthy,
note: videoParserOptionalGap ? '视频解析仍在开发中,当前测试环境未启用,不影响核心搜索服务。' : '',
};
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { createRouter, createMemoryHistory } from 'vue-router';
import { routes } from '../router';
describe('application routes', () => {
function makeRouter() {
return createRouter({ history: createMemoryHistory(), routes });
}
it('resolves public and admin routes before the catch-all not-found route', () => {
const router = makeRouter();
expect(router.resolve('/').name).toBe('home');
expect(router.resolve('/search?q=test').name).toBe('search');
expect(router.resolve('/result/abc123').name).toBe('result-detail');
expect(router.resolve('/admin/login').name).toBe('admin-login');
expect(router.resolve('/admin/dashboard').matched.some(route => route.name === 'admin-dashboard')).toBe(true);
});
it('has an explicit disclaimer route instead of relying on the not-found fallback', () => {
const router = makeRouter();
expect(router.resolve('/disclaimer').name).toBe('disclaimer');
expect(router.resolve('/disclaimer/').name).toBe('disclaimer');
});
it('resolves unknown frontend routes to the not-found page', () => {
const router = makeRouter();
expect(router.resolve('/nonexistent-ui-audit').name).toBe('not-found');
expect(router.resolve('/unknown/path').name).toBe('not-found');
});
});
@@ -0,0 +1,14 @@
const KNOWN_PUBLIC_ROUTE_PATTERNS = [
/^\/$/,
/^\/search\/?$/,
/^\/result\/[^/]+\/?$/,
/^\/admin\/login\/?$/,
/^\/user\/login\/?$/,
/^\/user\/dashboard\/?$/,
/^\/user\/?$/,
];
export function isKnownPublicRoute(path: string): boolean {
const cleanPath = path.split('?')[0].split('#')[0] || '/';
return KNOWN_PUBLIC_ROUTE_PATTERNS.some(pattern => pattern.test(cleanPath));
}
+7 -17
View File
@@ -1,8 +1,7 @@
import { getDb } from '../database/database';
import { localTimestamp, formatLocalDateTime } from '../utils/time';
import { getSystemConfig } from '../admin/system-config.service';
import { QuarkDriver } from './drivers/quark.driver';
import { BaiduDriver } from './drivers/baidu.driver';
import { createCloudDriver, supportsSaveFromShare } from './driver-factory';
import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service';
import { lookupIpLocation } from './ip-lookup';
import { notifyConfigEvent } from './notification.service';
@@ -173,22 +172,13 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
try {
let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; originalFolderName?: string };
switch (cloudType) {
case 'quark': {
const driver = new QuarkDriver({ cookie: config.cookie!, nickname: config.nickname });
driverResult = await driver.saveFromShare(shareUrl, sourceTitle, retrySave);
break;
}
case 'baidu': {
const driver = new BaiduDriver({ cookie: config.cookie!, nickname: config.nickname });
driverResult = await driver.saveFromShare(shareUrl, sourceTitle);
break;
}
case 'aliyun':
return { success: false, message: '阿里云盘保存功能暂未实现' };
default:
return { success: false, message: `暂不支持 ${cloudType} 的保存功能` };
const driver = createCloudDriver(cloudType, { cookie: config.cookie!, nickname: config.nickname });
if (!supportsSaveFromShare(driver)) {
return { success: false, message: `暂不支持 ${cloudType} 的保存功能` };
}
driverResult = cloudType === 'quark'
? await driver.saveFromShare(shareUrl, sourceTitle, retrySave)
: await driver.saveFromShare(shareUrl, sourceTitle);
const durationMs = Date.now() - startTime;
+37 -20
View File
@@ -1,4 +1,5 @@
import { QUARK_PAN_HOST, EP as QE } from './drivers/quark-api';
import { createCloudDriver, supportsValidate } from './driver-factory';
import { getDb } from '../database/database';
import { encrypt, decrypt, isEncrypted } from '../utils/crypto';
import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time';
@@ -237,11 +238,13 @@ export async function testCloudConnection(id: number): Promise<{
let storageUsed = config.storage_used || '';
let storageTotal = config.storage_total || '';
if (config.cloud_type === 'baidu') {
const { BaiduDriver } = require('./drivers/baidu.driver');
const driver = new BaiduDriver({ cookie: cookie, nickname: config.nickname });
const driver = createCloudDriver(config.cloud_type, { cookie: cookie, nickname: config.nickname }) as any;
if (supportsValidate(driver)) {
valid = await driver.validate();
if (valid) {
}
if (valid) {
if (config.cloud_type === 'baidu' && typeof driver.getUserInfo === 'function') {
const info = await driver.getUserInfo();
if (info) {
nickname = config.nickname || info.nickname || '百度网盘';
@@ -249,19 +252,24 @@ export async function testCloudConnection(id: number): Promise<{
storageUsed = fmt(info.usedBytes);
storageTotal = fmt(info.totalBytes);
}
}
} else {
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie: cookie, nickname: config.nickname });
valid = await driver.validate();
if (valid) {
} else if (config.cloud_type === 'quark') {
nickname = config.nickname || (await fetchQuarkNickname(cookie)) || '夸克网盘';
const storage = await driver.getStorageInfoQuick();
storageUsed = (storage.used !== '-' && storage.used !== '0 B') ? storage.used : (config.storage_used || '');
storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || '');
if (typeof driver.getStorageInfoQuick === 'function') {
const storage = await driver.getStorageInfoQuick();
storageUsed = (storage.used !== '-' && storage.used !== '0 B') ? storage.used : (config.storage_used || '');
storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || '');
}
} else if (config.cloud_type === 'uc') {
nickname = config.nickname || (typeof driver.getNickname === 'function' ? await driver.getNickname() : '') || 'UC网盘';
} else if (config.cloud_type === 'aliyun') {
nickname = config.nickname || (typeof driver.getNickname === 'function' ? await driver.getNickname() : '') || '阿里云盘';
}
}
if (valid && !nickname) {
nickname = config.nickname || (typeof driver.getNickname === 'function' ? await driver.getNickname() : '') || config.cloud_type;
}
const db = getDb();
if (!valid) {
db.prepare(
@@ -342,6 +350,18 @@ export async function testCloudConnectionWithCookie(cloudType: string, cookie: s
message: nickname ? '连接成功' : '连接成功(无法获取昵称)',
nickname: nickname || '阿里云盘',
};
} else if (cloudType === 'uc') {
const driver = createCloudDriver('uc', { cookie, nickname: '' }) as any;
const valid = supportsValidate(driver) ? await driver.validate() : false;
if (!valid) {
return { success: false, message: '连接失败:UC Cookie 无效或长度不足' };
}
const nickname = typeof driver.getNickname === 'function' ? await driver.getNickname() : 'UC网盘';
return {
success: true,
message: '连接成功',
nickname: nickname || 'UC网盘',
};
} else {
return {
success: true,
@@ -405,14 +425,11 @@ export async function getAndValidateCredential(cloudType: string): Promise<Crede
try {
let cookieValid = false;
if (cloudType === 'baidu') {
const { BaiduDriver } = require('./drivers/baidu.driver');
const driver = new BaiduDriver({ cookie: cookie, nickname: config.nickname });
cookieValid = await driver.validate();
} else {
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie: cookie, nickname: config.nickname });
const driver = createCloudDriver(cloudType, { cookie: cookie, nickname: config.nickname });
if (supportsValidate(driver)) {
cookieValid = await driver.validate();
} else if (cloudType === 'aliyun') {
cookieValid = true;
}
if (!cookieValid) {
@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
let tempDir = '';
async function loadCredentialService() {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloudsearch-credential-'));
process.env.DB_PATH = path.join(tempDir, 'cloudsearch.db');
process.env.DATA_DIR = tempDir;
process.env.COOKIE_ENCRYPTION_KEY = 'test-cookie-encryption-key-000000000000';
const service = await import('./credential.service');
return service;
}
describe('credential service UC integration', () => {
beforeEach(async () => {
await import('vitest').then(({ vi }) => vi.resetModules());
});
afterEach(async () => {
await import('vitest').then(({ vi }) => vi.resetModules());
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
delete process.env.DB_PATH;
delete process.env.DATA_DIR;
delete process.env.COOKIE_ENCRYPTION_KEY;
});
it('tests a saved Xunlei cloud config through the Xunlei driver instead of the unsupported fallback', async () => {
const { saveCloudConfig, testCloudConnection } = await loadCredentialService();
const saved = saveCloudConfig({
cloud_type: 'xunlei',
cookie: 'r'.repeat(32),
nickname: 'Xunlei Account',
is_active: 1,
});
const result = await testCloudConnection(saved.id);
expect(result).toMatchObject({
success: true,
message: '连接成功',
nickname: 'Xunlei Account',
});
});
it('tests a saved UC cloud config through the UC driver instead of the Quark fallback', async () => {
const { saveCloudConfig, testCloudConnection } = await loadCredentialService();
const saved = saveCloudConfig({
cloud_type: 'uc',
cookie: 'k=' + 'x'.repeat(80),
nickname: 'UC Account',
is_active: 1,
});
const result = await testCloudConnection(saved.id);
expect(result).toMatchObject({
success: true,
message: '连接成功',
nickname: 'UC Account',
});
});
});
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import fs from 'fs';
import path from 'path';
const cloudDir = path.resolve(__dirname);
const ucDir = path.join(cloudDir, 'drivers', 'uc');
const xunleiDir = path.join(cloudDir, 'drivers', 'xunlei');
const legacyUcFiles = [
path.join(cloudDir, 'drivers', 'uc.driver.ts'),
path.join(cloudDir, 'drivers', 'uc-api.ts'),
];
function readTsFiles(dir: string): Array<{ file: string; content: string }> {
if (!fs.existsSync(dir)) return [];
const entries = fs.readdirSync(dir, { withFileTypes: true });
const files: Array<{ file: string; content: string }> = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...readTsFiles(full));
if (entry.isFile() && full.endsWith('.ts')) files.push({ file: full, content: fs.readFileSync(full, 'utf8') });
}
return files;
}
describe('cloud driver module boundaries', () => {
it('keeps UC implementation in its own driver folder', () => {
expect(fs.existsSync(path.join(ucDir, 'driver.ts'))).toBe(true);
expect(fs.existsSync(path.join(ucDir, 'api.ts'))).toBe(true);
});
it('does not keep UC implementation in the flat drivers directory', () => {
for (const file of legacyUcFiles) {
if (!fs.existsSync(file)) continue;
const content = fs.readFileSync(file, 'utf8');
expect(content.trim()).toMatch(/^export \* from '\.\/uc\//);
}
});
it('does not let the UC module import or require Quark modules', () => {
const offenders = readTsFiles(ucDir).filter(({ content }) => /from ['"].*quark|require\(['"].*quark/.test(content));
expect(offenders.map(({ file }) => path.relative(cloudDir, file))).toEqual([]);
});
it('keeps Xunlei implementation in its own driver folder', () => {
expect(fs.existsSync(path.join(xunleiDir, 'driver.ts'))).toBe(true);
expect(fs.existsSync(path.join(xunleiDir, 'api.ts'))).toBe(true);
});
it('does not let the Xunlei module import or require Quark/UC modules', () => {
const offenders = readTsFiles(xunleiDir).filter(({ content }) => /from ['"].*(quark|uc)|require\(['"].*(quark|uc)/.test(content));
expect(offenders.map(({ file }) => path.relative(cloudDir, file))).toEqual([]);
});
});
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { createCloudDriver } from './driver-factory';
import { UcDriver } from './drivers/uc/driver';
import { XunleiDriver } from './drivers/xunlei/driver';
describe('createCloudDriver', () => {
it('creates a UC driver inside the main app process', () => {
const driver = createCloudDriver('uc', { cookie: 'k=' + 'x'.repeat(80), nickname: 'uc-test' });
expect(driver).toBeInstanceOf(UcDriver);
});
it('creates a Xunlei driver inside the main app process', () => {
const driver = createCloudDriver('xunlei', { refreshToken: 'r'.repeat(32), nickname: 'xl-test' });
expect(driver).toBeInstanceOf(XunleiDriver);
});
it('returns null for unsupported cloud types', () => {
expect(createCloudDriver('unsupported-drive', { cookie: 'x' })).toBeNull();
});
});
+33
View File
@@ -0,0 +1,33 @@
import { QuarkDriver, QuarkConfig } from './drivers/quark.driver';
import { BaiduDriver, BaiduConfig } from './drivers/baidu.driver';
import { AliyunDriver, AliyunConfig } from './drivers/aliyun.driver';
import { UcDriver, UcConfig } from './drivers/uc/driver';
import { XunleiDriver, XunleiConfig } from './drivers/xunlei/driver';
export type CloudDriver = QuarkDriver | BaiduDriver | AliyunDriver | UcDriver | XunleiDriver;
export type CloudDriverConfig = QuarkConfig | BaiduConfig | AliyunConfig | UcConfig | XunleiConfig;
export function createCloudDriver(cloudType: string, config: CloudDriverConfig): CloudDriver | null {
switch (cloudType) {
case 'quark':
return new QuarkDriver(config as QuarkConfig);
case 'baidu':
return new BaiduDriver(config as BaiduConfig);
case 'aliyun':
return new AliyunDriver(config as AliyunConfig);
case 'uc':
return new UcDriver(config as UcConfig);
case 'xunlei':
return new XunleiDriver(config as XunleiConfig);
default:
return null;
}
}
export function supportsSaveFromShare(driver: CloudDriver | null): driver is CloudDriver & { saveFromShare: (...args: any[]) => Promise<any> } {
return !!driver && typeof (driver as any).saveFromShare === 'function';
}
export function supportsValidate(driver: CloudDriver | null): driver is CloudDriver & { validate: () => Promise<boolean> } {
return !!driver && typeof (driver as any).validate === 'function';
}
+1
View File
@@ -0,0 +1 @@
export * from './uc/api';
@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { parseUcShareUrl, UC_API_BASE, UC_SHARE_API } from './uc/api';
import { UcDriver } from './uc/driver';
class MockResponse {
ok = true;
status = 200;
constructor(private payload: any) {}
async json() { return this.payload; }
}
describe('UcDriver', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('parses UC share ids from drive.uc.cn links', () => {
expect(parseUcShareUrl('https://drive.uc.cn/s/abcdef')).toBe('abcdef');
expect(parseUcShareUrl('https://example.com/s/abcdef')).toBeNull();
});
it('validates cookie presence with the same lightweight rule used by transfer reference', async () => {
await expect(new UcDriver({ cookie: 'too-short' }).validate()).resolves.toBe(false);
await expect(new UcDriver({ cookie: 'k=' + 'x'.repeat(80) }).validate()).resolves.toBe(true);
});
it('runs the UC save/share API flow and returns CloudSearch SaveResult shape', async () => {
const calls: Array<{ url: string; method?: string; body?: any }> = [];
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: any, init: any = {}) => {
const url = String(input);
const body = init.body ? JSON.parse(String(init.body)) : undefined;
calls.push({ url, method: init.method, body });
if (url.startsWith(`${UC_SHARE_API}/sharepage/v2/detail`)) {
return new MockResponse({ status: 0, data: { token_info: { stoken: 'stoken-1' } } }) as any;
}
if (url.startsWith(`${UC_SHARE_API}/sharepage/detail`)) {
return new MockResponse({ status: 0, data: { title: 'Demo Folder', fid: 'src-fid', share_fid_token: 'src-token' } }) as any;
}
if (url.startsWith(`${UC_SHARE_API}/sharepage/save`)) {
expect(body).toMatchObject({
fid_list: ['src-fid'],
fid_token_list: ['src-token'],
to_pdir_fid: '0',
pwd_id: 'abcdef',
stoken: 'stoken-1',
});
return new MockResponse({ status: 0, data: { task_id: 'save-task' } }) as any;
}
if (url.startsWith(`${UC_API_BASE}/1/clouddrive/task`) && url.includes('save-task')) {
return new MockResponse({ status: 200, data: { status: 2, save_as: { save_as_top_fids: ['new-fid'] } } }) as any;
}
if (url === UC_SHARE_API) {
expect(body).toMatchObject({ fid_list: ['new-fid'], title: 'Demo Folder' });
return new MockResponse({ status: 0, data: { task_id: 'share-task' } }) as any;
}
if (url.startsWith(`${UC_API_BASE}/1/clouddrive/task`) && url.includes('share-task')) {
return new MockResponse({ status: 200, data: { status: 2, share_id: 'new-share-id' } }) as any;
}
if (url.startsWith(`${UC_SHARE_API}/password`)) {
expect(body).toMatchObject({ share_id: 'new-share-id' });
return new MockResponse({ status: 0, data: { share_url: 'https://drive.uc.cn/s/newshare', passcode: 'pw12' } }) as any;
}
throw new Error(`Unexpected fetch: ${url}`);
});
const result = await new UcDriver({ cookie: 'k=' + 'x'.repeat(80), nickname: 'uc-test' }).saveFromShare('https://drive.uc.cn/s/abcdef');
expect(result).toMatchObject({
success: true,
message: '转存成功',
shareUrl: 'https://drive.uc.cn/s/newshare',
sharePwd: 'pw12',
folderName: 'Demo Folder',
fileCount: 1,
folderCount: 0,
});
expect(fetchMock).toHaveBeenCalledTimes(7);
expect(calls.map(c => c.url)).toContain(UC_SHARE_API);
});
});
@@ -0,0 +1 @@
export * from './uc/driver';
+44
View File
@@ -0,0 +1,44 @@
// UC 网盘 API 常量与轻量工具
export const UC_API_BASE = 'https://pc-api.uc.cn';
export const UC_WEB_HOST = 'https://drive.uc.cn';
export const UC_SHARE_API = `${UC_API_BASE}/1/clouddrive/share`;
export const UC_EP = {
SHARE_PAGE_TOKEN: '/sharepage/v2/detail',
SHARE_PAGE_DETAIL: '/sharepage/detail',
SHARE_PAGE_SAVE: '/sharepage/save',
SHARE_PASSWORD: '/password',
TASK: '/1/clouddrive/task',
} as const;
export function getUcHeaders(cookie: string): Record<string, string> {
return {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Cookie': cookie,
'Referer': `${UC_WEB_HOST}/`,
'Origin': UC_WEB_HOST,
};
}
export function parseUcShareUrl(shareUrl: string): string | null {
try {
const url = new URL(shareUrl);
if (!url.hostname.includes('drive.uc.cn')) return null;
const match = url.pathname.match(/\/s\/([a-zA-Z0-9_-]+)/);
return match?.[1] || null;
} catch {
const match = shareUrl.match(/drive\.uc\.cn\/s\/([a-zA-Z0-9_-]+)/);
return match?.[1] || null;
}
}
export function getUcCommonParams(extra: Record<string, string | number> = {}): URLSearchParams {
return new URLSearchParams({
pr: 'UCBrowser',
fr: 'pc',
...Object.fromEntries(Object.entries(extra).map(([k, v]) => [k, String(v)])),
});
}
+207
View File
@@ -0,0 +1,207 @@
import { getUcCommonParams, getUcHeaders, parseUcShareUrl, UC_API_BASE, UC_EP, UC_SHARE_API } from './api';
export interface UcConfig {
cookie?: string;
nickname?: string;
}
interface UcDetail {
title?: string;
fid?: string;
fid_list?: string[];
share_fid_token?: string;
fid_token_list?: string[];
[key: string]: any;
}
interface UcDriverResult {
success: boolean;
message: string;
shareUrl?: string;
sharePwd?: string;
folderName?: string;
fileCount?: number;
folderCount?: number;
}
export class UcDriver {
private config: UcConfig;
constructor(config: UcConfig = {}) {
this.config = { ...config };
}
private get cookie(): string {
return this.config.cookie || '';
}
async validate(): Promise<boolean> {
return this.cookie.length >= 50;
}
async getNickname(): Promise<string | null> {
return this.config.nickname || 'UC网盘';
}
private headers(json = false): Record<string, string> {
return {
...getUcHeaders(this.cookie),
...(json ? { 'Content-Type': 'application/json' } : {}),
};
}
private async readJson(response: Response, context: string): Promise<any> {
if (!response.ok) {
throw new Error(`${context}失败: HTTP ${response.status}`);
}
return response.json();
}
private apiOk(data: any): boolean {
return data?.status === 0 || data?.status === 200 || data?.code === 0;
}
private async getStoken(pwdId: string, passcode = ''): Promise<string> {
const url = `${UC_SHARE_API}${UC_EP.SHARE_PAGE_TOKEN}?${getUcCommonParams().toString()}`;
const response = await fetch(url, {
method: 'POST',
headers: this.headers(true),
body: JSON.stringify({ passcode, pwd_id: pwdId }),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '获取UC stoken');
const stoken = data?.data?.token_info?.stoken;
if (!stoken) throw new Error(`获取UC stoken失败: ${data?.message || 'stoken缺失'}`);
return stoken;
}
private async getDetail(pwdId: string, stoken: string): Promise<UcDetail> {
const params = getUcCommonParams({ pwd_id: pwdId, stoken, _fetch_share: '1' });
const response = await fetch(`${UC_SHARE_API}${UC_EP.SHARE_PAGE_DETAIL}?${params.toString()}`, {
headers: this.headers(),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '获取UC分享详情');
if (!this.apiOk(data) || !data?.data) {
throw new Error(`获取UC分享详情失败: ${data?.message || '详情为空'}`);
}
return data.data;
}
private normalizeDetailLists(detail: UcDetail): { fidList: string[]; fidTokenList: string[] } {
let fidList = detail.fid_list || (detail.fid ? [detail.fid] : []);
let fidTokenList = detail.fid_token_list || (detail.share_fid_token ? [detail.share_fid_token] : []);
if (!Array.isArray(fidList)) fidList = fidList ? [fidList] : [];
if (!Array.isArray(fidTokenList)) fidTokenList = fidTokenList ? [fidTokenList] : [];
return { fidList, fidTokenList };
}
private async initSave(pwdId: string, stoken: string, detail: UcDetail, toPdirFid = '0'): Promise<string> {
const { fidList, fidTokenList } = this.normalizeDetailLists(detail);
const response = await fetch(`${UC_SHARE_API}${UC_EP.SHARE_PAGE_SAVE}`, {
method: 'POST',
headers: this.headers(true),
body: JSON.stringify({
fid_list: fidList,
fid_token_list: fidTokenList,
to_pdir_fid: toPdirFid,
pwd_id: pwdId,
stoken,
pdir_fid: '0',
scene: 'link',
}),
signal: AbortSignal.timeout(30000),
});
const data = await this.readJson(response, '发起UC转存');
if (!this.apiOk(data) || !data?.data?.task_id) {
throw new Error(`发起UC转存失败: ${data?.message || 'task_id缺失'}`);
}
return data.data.task_id;
}
private async pollTask(taskId: string, mode: 'save' | 'share'): Promise<any> {
for (let retryIndex = 0; retryIndex < 50; retryIndex++) {
const params = getUcCommonParams({ task_id: taskId, retry_index: retryIndex });
const response = await fetch(`${UC_API_BASE}${UC_EP.TASK}?${params.toString()}`, {
headers: this.headers(),
signal: AbortSignal.timeout(10000),
});
const data = await this.readJson(response, '查询UC任务');
const status = data?.data?.status;
if (status === 2) return data.data;
if (status === -1 || status === 3) throw new Error(`UC${mode === 'save' ? '转存' : '分享'}任务失败: ${data?.message || taskId}`);
await new Promise(r => setTimeout(r, 500));
}
throw new Error(`UC${mode === 'save' ? '转存' : '分享'}任务超时: ${taskId}`);
}
private async initShare(fileIds: string[], title: string): Promise<string> {
const response = await fetch(UC_SHARE_API, {
method: 'POST',
headers: this.headers(true),
body: JSON.stringify({ fid_list: fileIds, title: title || '分享', expired_type: 1 }),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '创建UC分享');
if (!this.apiOk(data) || !data?.data?.task_id) {
throw new Error(`创建UC分享失败: ${data?.message || 'task_id缺失'}`);
}
return data.data.task_id;
}
private async setPassword(shareId: string, password = ''): Promise<{ shareUrl: string; passcode: string }> {
const response = await fetch(`${UC_SHARE_API}${UC_EP.SHARE_PASSWORD}`, {
method: 'POST',
headers: this.headers(true),
body: JSON.stringify({ share_id: shareId }),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '设置UC分享密码');
if (!this.apiOk(data)) {
throw new Error(`设置UC分享密码失败: ${data?.message || 'API错误'}`);
}
return {
shareUrl: data?.data?.share_url || `https://drive.uc.cn/s/${shareId}`,
passcode: data?.data?.passcode || password || '',
};
}
async saveFromShare(shareUrl: string, _sourceTitle?: string): Promise<UcDriverResult> {
if (!(await this.validate())) {
return { success: false, message: 'UC Cookie 无效或长度不足' };
}
const pwdId = parseUcShareUrl(shareUrl);
if (!pwdId) {
return { success: false, message: '无法解析UC分享链接' };
}
try {
const stoken = await this.getStoken(pwdId);
const detail = await this.getDetail(pwdId, stoken);
const saveTaskId = await this.initSave(pwdId, stoken, detail, '0');
const saveTask = await this.pollTask(saveTaskId, 'save');
const newFileIds: string[] = saveTask?.save_as?.save_as_top_fids || [];
if (!newFileIds.length) throw new Error('UC转存完成但未返回文件ID');
const title = detail.title || _sourceTitle || '分享';
const shareTaskId = await this.initShare(newFileIds, title);
const shareTask = await this.pollTask(shareTaskId, 'share');
const shareId = shareTask?.share_id || shareTask?.result?.share_id;
if (!shareId) throw new Error('UC分享任务完成但未返回share_id');
const shared = await this.setPassword(shareId);
return {
success: true,
message: '转存成功',
shareUrl: shared.shareUrl,
sharePwd: shared.passcode,
folderName: title,
fileCount: newFileIds.length,
folderCount: 0,
};
} catch (err: any) {
return { success: false, message: err?.message || 'UC转存失败' };
}
}
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { parseXunleiShareUrl, getXunleiHeaders } from './xunlei/api';
import { XunleiDriver } from './xunlei/driver';
describe('Xunlei cloud driver', () => {
it('parses common Xunlei share URLs', () => {
expect(parseXunleiShareUrl('https://pan.xunlei.com/s/VNabc123xyz?pwd=7k9m')).toEqual({ shareId: 'VNabc123xyz', passcode: '7k9m' });
expect(parseXunleiShareUrl('https://pan.xunlei.com/s/VNabc123xyz')).toEqual({ shareId: 'VNabc123xyz', passcode: '' });
});
it('rejects non-Xunlei share URLs', () => {
expect(parseXunleiShareUrl('https://drive.uc.cn/s/abcdef')).toBeNull();
});
it('builds bearer headers from refresh-token derived access tokens', () => {
expect(getXunleiHeaders('access-token-1').Authorization).toBe('Bearer access-token-1');
});
it('validates refresh token presence without doing network IO', async () => {
await expect(new XunleiDriver({ refreshToken: '' }).validate()).resolves.toBe(false);
await expect(new XunleiDriver({ refreshToken: 'r'.repeat(32), nickname: 'xl-test' }).validate()).resolves.toBe(true);
});
});
@@ -0,0 +1 @@
export * from './xunlei/driver';
@@ -0,0 +1,50 @@
// 迅雷网盘 API 常量与轻量工具
export const XUNLEI_PAN_API = 'https://api-pan.xunlei.com';
export const XUNLEI_AUTH_API = 'https://xluser-ssl.xunlei.com';
export const XUNLEI_WEB_HOST = 'https://pan.xunlei.com';
export const XUNLEI_CLIENT_ID = 'Xqp0kJBXWhwaTpB6';
export const XUNLEI_DEVICE_ID = '925b7631473a13716b791d7f28289cad';
export const XUNLEI_EP = {
SHARE_INFO: '/drive/v1/share',
SHARE_RESTORE: '/drive/v1/share/restore',
TASK: '/drive/v1/tasks',
CREATE_SHARE: '/drive/v1/share',
FILES: '/drive/v1/files',
TOKEN: '/v1/auth/token',
} as const;
export interface XunleiShareParts {
shareId: string;
passcode: string;
}
export function parseXunleiShareUrl(shareUrl: string): XunleiShareParts | null {
try {
const url = new URL(shareUrl);
if (!url.hostname.includes('pan.xunlei.com')) return null;
const match = url.pathname.match(/\/s\/([A-Za-z0-9_-]+)/);
if (!match) return null;
return { shareId: match[1], passcode: url.searchParams.get('pwd') || '' };
} catch {
const match = shareUrl.match(/pan\.xunlei\.com\/s\/([A-Za-z0-9_-]+)/);
if (!match) return null;
const pwd = shareUrl.match(/[?&]pwd=([A-Za-z0-9_-]+)/)?.[1] || '';
return { shareId: match[1], passcode: pwd };
}
}
export function getXunleiHeaders(accessToken = '', captchaToken = ''): Record<string, string> {
const headers: Record<string, string> = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'x-client-id': XUNLEI_CLIENT_ID,
'x-device-id': XUNLEI_DEVICE_ID,
};
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
if (captchaToken) headers['x-captcha-token'] = captchaToken;
return headers;
}
@@ -0,0 +1,179 @@
import {
getXunleiHeaders,
parseXunleiShareUrl,
XUNLEI_AUTH_API,
XUNLEI_CLIENT_ID,
XUNLEI_EP,
XUNLEI_PAN_API,
} from './api';
export interface XunleiConfig {
refreshToken?: string;
cookie?: string;
nickname?: string;
}
interface XunleiDriverResult {
success: boolean;
message: string;
shareUrl?: string;
sharePwd?: string;
folderName?: string;
fileCount?: number;
folderCount?: number;
}
interface XunleiShareInfo {
pass_code_token?: string;
files?: Array<{ id?: string; file_id?: string; name?: string; kind?: string; is_dir?: boolean }>;
title?: string;
share_name?: string;
[key: string]: any;
}
export class XunleiDriver {
private config: XunleiConfig;
private accessToken = '';
private accessTokenExpiresAt = 0;
constructor(config: XunleiConfig = {}) {
this.config = { ...config };
}
private get refreshToken(): string {
return (this.config.refreshToken || this.config.cookie || '').trim();
}
async validate(): Promise<boolean> {
// 测试环境先做离线格式校验,真实 API 调用在 saveFromShare 时换取 access_token。
return this.refreshToken.length >= 20;
}
async getNickname(): Promise<string | null> {
return this.config.nickname || '迅雷网盘';
}
private async readJson(response: Response, context: string): Promise<any> {
if (!response.ok) throw new Error(`${context}失败: HTTP ${response.status}`);
return response.json();
}
private apiOk(data: any): boolean {
return data?.errcode === 0 || data?.error_code === 0 || data?.code === 0 || (!data?.errcode && !data?.error_code && !data?.code);
}
private async getAccessToken(): Promise<string> {
if (this.accessToken && Date.now() < this.accessTokenExpiresAt - 60000) return this.accessToken;
if (!(await this.validate())) throw new Error('迅雷 refresh_token 无效或长度不足');
const response = await fetch(`${XUNLEI_AUTH_API}${XUNLEI_EP.TOKEN}`, {
method: 'POST',
headers: getXunleiHeaders(),
body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: this.refreshToken, client_id: XUNLEI_CLIENT_ID }),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '刷新迅雷 access_token');
if (!data?.access_token) throw new Error(`刷新迅雷 access_token失败: ${data?.message || data?.error || 'access_token缺失'}`);
this.accessToken = data.access_token;
this.accessTokenExpiresAt = Date.now() + Number(data.expires_in || 7200) * 1000;
if (data.refresh_token) this.config.refreshToken = data.refresh_token;
return this.accessToken;
}
private async headers(): Promise<Record<string, string>> {
return getXunleiHeaders(await this.getAccessToken());
}
private async getShareInfo(shareId: string, passcode = ''): Promise<XunleiShareInfo> {
const params = new URLSearchParams({ share_id: shareId });
if (passcode) params.set('pass_code', passcode);
const response = await fetch(`${XUNLEI_PAN_API}${XUNLEI_EP.SHARE_INFO}?${params.toString()}`, {
headers: await this.headers(),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '获取迅雷分享详情');
if (!this.apiOk(data)) throw new Error(`获取迅雷分享详情失败: ${data?.message || data?.error || 'API错误'}`);
const files = data.files || data.data?.files || [];
if (!files.length) throw new Error('迅雷分享内容为空');
return { ...data, ...(data.data || {}), files };
}
private extractFileIds(files: XunleiShareInfo['files']): string[] {
return (files || []).map(file => file.id || file.file_id || '').filter(Boolean);
}
private async restoreFiles(shareId: string, passCodeToken: string, fileIds: string[]): Promise<string> {
const response = await fetch(`${XUNLEI_PAN_API}${XUNLEI_EP.SHARE_RESTORE}`, {
method: 'POST',
headers: await this.headers(),
body: JSON.stringify({ file_ids: fileIds, pass_code_token: passCodeToken, share_id: shareId, parent_id: '', specify_parent_id: true }),
signal: AbortSignal.timeout(30000),
});
const data = await this.readJson(response, '发起迅雷转存');
if (!this.apiOk(data)) throw new Error(`发起迅雷转存失败: ${data?.message || data?.error || 'API错误'}`);
const taskId = data.restore_task_id || data.task_id || data.data?.restore_task_id || data.data?.task_id;
if (!taskId) throw new Error('发起迅雷转存失败: task_id缺失');
return taskId;
}
private parseTraceFileIds(trace: any): Record<string, string> {
if (!trace) return {};
const parsed = typeof trace === 'string' ? JSON.parse(trace || '{}') : trace;
return Object.fromEntries(Object.entries(parsed).map(([oldId, value]) => [oldId, typeof value === 'string' ? value : (value as any)?.id || (value as any)?.file_id || '']).filter(([, newId]) => !!newId));
}
private async pollRestoreTask(taskId: string): Promise<string[]> {
for (let attempt = 0; attempt < 50; attempt++) {
const response = await fetch(`${XUNLEI_PAN_API}${XUNLEI_EP.TASK}/${taskId}`, {
headers: await this.headers(),
signal: AbortSignal.timeout(10000),
});
const data = await this.readJson(response, '查询迅雷转存任务');
if (data.status === 'failed' || data.status === 'error') throw new Error(`迅雷转存任务失败: ${taskId}`);
if (data.progress === 100 || data.status === 'success' || data.status === 'complete') {
const mapping = this.parseTraceFileIds(data.params?.trace_file_ids || data.trace_file_ids || data.data?.params?.trace_file_ids);
const ids = Object.values(mapping);
if (ids.length) return ids;
if (Array.isArray(data.file_ids)) return data.file_ids;
if (Array.isArray(data.data?.file_ids)) return data.data.file_ids;
throw new Error('迅雷转存完成但未返回文件ID');
}
await new Promise(resolve => setTimeout(resolve, 500));
}
throw new Error(`迅雷转存任务超时: ${taskId}`);
}
private async createShare(fileIds: string[], title: string): Promise<{ shareUrl: string; passcode: string }> {
const response = await fetch(`${XUNLEI_PAN_API}${XUNLEI_EP.CREATE_SHARE}`, {
method: 'POST',
headers: await this.headers(),
body: JSON.stringify({ file_ids: fileIds, title: title || '分享' }),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '创建迅雷分享');
if (!this.apiOk(data)) throw new Error(`创建迅雷分享失败: ${data?.message || data?.error || 'API错误'}`);
const payload = data.data || data;
const shareUrl = payload.share_url || payload.url;
if (!shareUrl) throw new Error('创建迅雷分享失败: share_url缺失');
return { shareUrl, passcode: payload.pass_code || payload.passcode || '' };
}
async saveFromShare(shareUrl: string, sourceTitle?: string): Promise<XunleiDriverResult> {
if (!(await this.validate())) return { success: false, message: '迅雷 refresh_token 无效或长度不足' };
const parsed = parseXunleiShareUrl(shareUrl);
if (!parsed) return { success: false, message: '无法解析迅雷分享链接' };
try {
const info = await this.getShareInfo(parsed.shareId, parsed.passcode);
const sourceFileIds = this.extractFileIds(info.files);
if (!sourceFileIds.length) throw new Error('无法从迅雷分享中提取文件ID');
const restoreTaskId = await this.restoreFiles(parsed.shareId, info.pass_code_token || '', sourceFileIds);
const savedFileIds = await this.pollRestoreTask(restoreTaskId);
const title = info.title || info.share_name || sourceTitle || '分享';
const shared = await this.createShare(savedFileIds, title);
return { success: true, message: '转存成功', shareUrl: shared.shareUrl, sharePwd: shared.passcode, folderName: title, fileCount: savedFileIds.length, folderCount: 0 };
} catch (err: any) {
return { success: false, message: err?.message || '迅雷转存失败' };
}
}
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import {
mergeNotifySettingsUpdate,
mergePushUserNotifyConfigUpdate,
mergeSystemConfigEntriesUpdate,
} from './admin-config-helpers';
describe('admin config update helpers', () => {
it('preserves existing secrets when redacted system config values are submitted', () => {
const existing = [
{ key: 'tmdb_api_token', value: 'real-token' },
{ key: 'site_name', value: 'Old name' },
{ key: 'global_notify_config', value: JSON.stringify({ channels: { webhook: { webhook_url: 'https://hook.invalid/real', enabled: true } } }) },
];
const incoming = [
{ key: 'tmdb_api_token', value: '***REDACTED***' },
{ key: 'site_name', value: 'New name' },
{ key: 'global_notify_config', value: JSON.stringify({ channels: { webhook: { webhook_url: '***REDACTED***', enabled: false } } }) },
];
const merged = mergeSystemConfigEntriesUpdate(incoming, existing);
expect(merged).toEqual([
{ key: 'tmdb_api_token', value: 'real-token' },
{ key: 'site_name', value: 'New name' },
{ key: 'global_notify_config', value: JSON.stringify({ channels: { webhook: { webhook_url: 'https://hook.invalid/real', enabled: false } } }) },
]);
});
it('preserves existing per-cloud notification secrets from redacted placeholders', () => {
const existing = { channels: { webhook: { webhook_url: 'https://hook.invalid/real', enabled: true } } };
const incoming = { channels: { webhook: { webhook_url: '***REDACTED***', enabled: false } } };
expect(mergeNotifySettingsUpdate(incoming, existing)).toEqual({
channels: { webhook: { webhook_url: 'https://hook.invalid/real', enabled: false } },
});
});
it('preserves existing push-user notification secrets from redacted placeholders', () => {
const existing = { notify_config: JSON.stringify({ channels: { bark: { token: 'real-token', enabled: true } } }) };
const incoming = { notify_config: { channels: { bark: { token: '***REDACTED***', enabled: false } } } };
expect(mergePushUserNotifyConfigUpdate(incoming, existing)).toEqual({
channels: { bark: { token: 'real-token', enabled: false } },
});
});
});
@@ -0,0 +1,46 @@
import { SystemConfigEntry } from '../admin/system-config.service';
import { mergeRedactedUpdate } from '../utils/redact';
function parseJsonObject(value: unknown): any {
if (!value) return {};
if (typeof value === 'object') return value;
if (typeof value !== 'string') return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function mergeJsonStringUpdate(incoming: string, existing: string | undefined): string {
try {
const parsedIncoming = JSON.parse(incoming);
const parsedExisting = parseJsonObject(existing);
return JSON.stringify(mergeRedactedUpdate(parsedIncoming, parsedExisting));
} catch {
return mergeRedactedUpdate(incoming, existing);
}
}
export function mergeSystemConfigEntriesUpdate(
entries: Array<{ key: string; value: string }>,
existing: SystemConfigEntry[],
): Array<{ key: string; value: string }> {
const existingByKey = new Map(existing.map(entry => [entry.key, entry.value]));
return entries.map(entry => {
const oldValue = existingByKey.get(entry.key);
return { ...entry, value: mergeJsonStringUpdate(entry.value, oldValue) };
});
}
export function mergeNotifySettingsUpdate(incoming: any, existing: any): any {
return mergeRedactedUpdate(incoming || {}, existing || {});
}
export function mergePushUserNotifyConfigUpdate(incoming: { notify_config?: any }, existing?: { notify_config?: string }): any {
return mergeNotifySettingsUpdate(
parseJsonObject(incoming?.notify_config),
parseJsonObject(existing?.notify_config),
);
}
+29 -18
View File
@@ -19,6 +19,8 @@ import { reconnectRedis, testRedisConnection } from '../middleware/cache';
import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service';
import { BaiduDriver } from '../cloud/drivers/baidu.driver';
import { testProxyConnection } from '../utils/proxy-agent';
import { mergeRedactedUpdate, redactSensitive } from '../utils/redact';
import { mergeNotifySettingsUpdate, mergePushUserNotifyConfigUpdate, mergeSystemConfigEntriesUpdate } from './admin-config-helpers';
const router = Router();
@@ -147,7 +149,7 @@ router.use('/admin', authMiddleware);
router.get('/admin/cloud-configs', (_req: Request, res: Response) => {
try {
const configs = getCloudConfigs();
res.json(configs);
res.json(redactSensitive(configs));
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to fetch cloud configs' });
}
@@ -182,7 +184,7 @@ router.post('/admin/cloud-configs', async (req: Request, res: Response) => {
}
}
res.json(saved);
res.json(redactSensitive(saved));
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to save cloud config' });
}
@@ -197,8 +199,9 @@ router.put('/admin/cloud-configs/:id', (req: Request, res: Response) => {
res.status(404).json({ error: 'Cloud config not found' });
return;
}
const saved = saveCloudConfig({ ...req.body, id });
res.json(saved);
const update = mergeRedactedUpdate(req.body, existing);
const saved = saveCloudConfig({ ...update, id });
res.json(redactSensitive(saved));
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to update cloud config' });
}
@@ -368,7 +371,7 @@ router.get('/admin/save-records', (req: Request, res: Response) => {
router.get('/admin/system-configs', (_req: Request, res: Response) => {
try {
const configs = getAllSystemConfigs();
res.json(configs);
res.json(redactSensitive(configs));
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get system configs' });
}
@@ -382,7 +385,8 @@ router.put('/admin/system-configs', (req: Request, res: Response) => {
res.status(400).json({ error: 'entries array is required' });
return;
}
updateSystemConfigs(entries);
const mergedEntries = mergeSystemConfigEntriesUpdate(entries, getAllSystemConfigs());
updateSystemConfigs(mergedEntries);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to update system configs' });
@@ -479,7 +483,7 @@ router.get('/admin/db-status', async (_req: Request, res: Response) => {
db_path: dbFile,
...counts,
redis_status,
redis_url,
redis_url: redis_url ? '***REDACTED***' : redis_url,
});
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get DB status' });
@@ -720,7 +724,7 @@ router.get('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const settings = getConfigNotifySettingsJSON(id);
res.json(settings);
res.json(redactSensitive(settings));
} catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to get notification settings' });
}
@@ -730,7 +734,8 @@ router.get('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => {
router.put('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const settings = req.body;
const existing = getConfigNotifySettingsJSON(id);
const settings = mergeNotifySettingsUpdate(req.body, existing);
saveConfigNotifySettings(id, settings);
res.json({ success: true, message: 'Push config saved' });
} catch (err: any) {
@@ -764,7 +769,7 @@ router.get('/admin/notify/providers', (_req: Request, res: Response) => {
router.get('/admin/notify/global-config', (_req, res) => {
try {
const cfg = getGlobalNotifyConfig();
res.json(cfg);
res.json(redactSensitive(cfg));
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get global config' });
}
@@ -778,7 +783,9 @@ router.put('/admin/notify/global-config', (req, res) => {
res.status(400).json({ error: 'Invalid config object' });
return;
}
updateSystemConfig('global_notify_config', JSON.stringify(cfg));
const existing = getGlobalNotifyConfig();
const merged = mergeNotifySettingsUpdate(cfg, existing);
updateSystemConfig('global_notify_config', JSON.stringify(merged));
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to save global config' });
@@ -793,7 +800,7 @@ router.get('/admin/push-users', (_req: Request, res: Response) => {
...u,
notify_config: (() => { try { return JSON.parse(u.notify_config); } catch { return {}; } })(),
}));
res.json(parsed);
res.json(redactSensitive(parsed));
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to list push users' });
}
@@ -804,9 +811,11 @@ router.post('/admin/push-users', (req: Request, res: Response) => {
try {
const { account, notify_config } = req.body;
if (!account) return res.status(400).json({ error: 'account is required' });
const configStr = typeof notify_config === 'string' ? notify_config : JSON.stringify(notify_config || {});
const existing = getAllPushUsers().find(u => u.account === account);
const mergedConfig = mergePushUserNotifyConfigUpdate({ notify_config }, existing);
const configStr = JSON.stringify(mergedConfig);
const user = upsertPushUser(account, configStr);
res.json({ ...user, notify_config: JSON.parse(user!.notify_config) });
res.json(redactSensitive({ ...user, notify_config: JSON.parse(user!.notify_config) }));
} catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to save push user' });
}
@@ -818,9 +827,11 @@ router.put('/admin/push-users/:id', (req: Request, res: Response) => {
const id = parseInt(req.params.id as string);
const { account, notify_config } = req.body;
if (!account) return res.status(400).json({ error: 'account is required' });
const configStr = typeof notify_config === 'string' ? notify_config : JSON.stringify(notify_config || {});
const existing = getAllPushUsers().find(u => u.id === id);
const mergedConfig = mergePushUserNotifyConfigUpdate({ notify_config }, existing);
const configStr = JSON.stringify(mergedConfig);
const user = updatePushUser(id, account, configStr);
res.json({ ...user, notify_config: JSON.parse(user!.notify_config) });
res.json(redactSensitive({ ...user, notify_config: JSON.parse(user!.notify_config) }));
} catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to update push user' });
}
@@ -849,7 +860,7 @@ router.get('/admin/daily-report/config', (_req, res) => {
try {
const { getDailyReportConfig } = require('../services/daily-report.service');
const cfg = getDailyReportConfig();
res.json(cfg);
res.json(redactSensitive(cfg));
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get daily report config' });
}
@@ -861,7 +872,7 @@ router.put('/admin/daily-report/config', (req, res) => {
const { saveDailyReportConfig } = require('../services/daily-report.service');
saveDailyReportConfig(req.body);
const { getDailyReportConfig } = require('../services/daily-report.service');
res.json(getDailyReportConfig());
res.json(redactSensitive(getDailyReportConfig()));
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to save daily report config' });
}
+2 -1
View File
@@ -4,6 +4,7 @@ import adminRoutes from "./admin.routes";
import uploadRoutes from "./upload.routes";
import cleanupRoutes from "./cleanup.routes";
import { getAllSystemConfigs } from "../admin/system-config.service";
import { publicSystemConfigs } from "../utils/redact";
const router = Router();
@@ -11,7 +12,7 @@ const router = Router();
router.get("/system-configs", (_req, res) => {
try {
const configs = getAllSystemConfigs();
res.json(configs);
res.json(publicSystemConfigs(configs));
} catch (err: any) {
res.status(500).json({ error: err.message || "Failed to get system configs" });
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { filterPublicRankingRows, isPublicRankingKeyword } from './keyword-quality';
describe('isPublicRankingKeyword', () => {
it('rejects script probes and html-like tags from public rankings', () => {
expect(isPublicRankingKeyword('<script>alert(1)</script>')).toBe(false);
expect(isPublicRankingKeyword('<img src=x onerror=alert(1)>')).toBe(false);
});
it('rejects obvious test keywords from public rankings', () => {
expect(isPublicRankingKeyword('test')).toBe(false);
expect(isPublicRankingKeyword('测试')).toBe(false);
expect(isPublicRankingKeyword(' 测试数据 ')).toBe(false);
});
it('keeps normal user search keywords', () => {
expect(isPublicRankingKeyword('庆余年')).toBe(true);
expect(isPublicRankingKeyword('4K 纪录片')).toBe(true);
});
it('keeps normal business keywords that start with testing-related words', () => {
expect(isPublicRankingKeyword('测试工程师教程')).toBe(true);
expect(isPublicRankingKeyword('测试开发面试')).toBe(true);
});
it('fills public ranking rows after filtering junk from a larger candidate set', () => {
const rows = [
{ keyword: 'test', count: 999 },
{ keyword: '<script>alert(1)</script>', count: 998 },
{ keyword: '庆余年', count: 10 },
{ keyword: '繁花', count: 9 },
];
expect(filterPublicRankingRows(rows, 2).map(row => row.keyword)).toEqual(['庆余年', '繁花']);
});
});
@@ -0,0 +1,24 @@
const TEST_KEYWORDS = new Set(['test', '测试', '测试数据']);
const HTML_TAG_PATTERN = /<\s*\/?\s*[a-z][^>]*>/i;
const SCRIPTISH_PATTERN = /(javascript\s*:|on\w+\s*=|<\s*script\b|alert\s*\()/i;
export interface PublicRankingRow {
keyword: string | null | undefined;
[key: string]: any;
}
export function isPublicRankingKeyword(keyword: string | null | undefined): boolean {
const normalized = String(keyword ?? '').trim();
if (!normalized) return false;
const lower = normalized.toLowerCase();
if (TEST_KEYWORDS.has(lower)) return false;
if (/^测试(?:\s*\d+|数据\d*)$/i.test(normalized)) return false;
if (HTML_TAG_PATTERN.test(normalized)) return false;
if (SCRIPTISH_PATTERN.test(normalized)) return false;
return true;
}
export function filterPublicRankingRows<T extends PublicRankingRow>(rows: T[], limit: number): T[] {
return rows.filter(row => isPublicRankingKeyword(row.keyword)).slice(0, limit);
}
+9 -6
View File
@@ -6,6 +6,7 @@
*/
import { getDb } from '../database/database';
import { formatLocalDateTime } from '../utils/time';
import { filterPublicRankingRows, isPublicRankingKeyword } from './keyword-quality';
// ==================== 类型定义 ====================
@@ -131,9 +132,10 @@ async function generateSearchPulse(): Promise<SearchPulse> {
const totalSearches = stats?.totalSearches || 0;
// 2. 热榜 Top 20
const hotRows = db.prepare(
'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
const rawHotRows = db.prepare(
'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 500'
).all() as any[];
const hotRows = filterPublicRankingRows(rawHotRows, 20);
const maxHotCount = hotRows.length > 0 ? hotRows[0].count : 1;
@@ -151,9 +153,10 @@ async function generateSearchPulse(): Promise<SearchPulse> {
});
// 3. 最新搜索 Top 15
const recentRows = db.prepare(
'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY updated_at DESC LIMIT 15'
const rawRecentRows = db.prepare(
'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY updated_at DESC LIMIT 500'
).all() as any[];
const recentRows = filterPublicRankingRows(rawRecentRows, 15);
const recentList: HotKeyword[] = recentRows.map((row: any) => {
const genre = detectGenre(row.keyword);
@@ -169,9 +172,9 @@ async function generateSearchPulse(): Promise<SearchPulse> {
});
// 4. 按分类聚合
const allKeywords = db.prepare(
const allKeywords = (db.prepare(
'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords WHERE search_count >= 3 ORDER BY search_count DESC'
).all() as any[];
).all() as any[]).filter(row => isPublicRankingKeyword(row.keyword));
const categoryMap = new Map<string, HotKeyword[]>();
for (const row of allKeywords) {
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import { mergeRedactedUpdate, publicSystemConfigs, redactSensitive } from './redact';
describe('redactSensitive', () => {
it('redacts sensitive fields recursively while preserving non-sensitive data', () => {
const input = {
id: 1,
cookie: 'raw-cookie-secret',
password_hash: '$2a$secret-hash',
nested: {
token: 'secret-token',
webhook_url: 'https://example.invalid/hook/key',
keep: 'ok',
},
arr: [{ password: 'secret-password', value: 1 }],
};
expect(redactSensitive(input)).toEqual({
id: 1,
cookie: '***REDACTED***',
password_hash: '***REDACTED***',
nested: {
token: '***REDACTED***',
webhook_url: '***REDACTED***',
keep: 'ok',
},
arr: [{ password: '***REDACTED***', value: 1 }],
});
});
it('redacts system config values based on their config key while preserving key names', () => {
const input = [
{ key: 'tmdb_api_token', value: 'real-token', description: 'TMDB token' },
{ key: 'site_name', value: 'CloudSearch' },
];
expect(redactSensitive(input)).toEqual([
{ key: 'tmdb_api_token', value: '***REDACTED***', description: 'TMDB token' },
{ key: 'site_name', value: 'CloudSearch' },
]);
});
it('preserves existing secret values when redacted placeholders are submitted in updates', () => {
const existing = {
cookie: 'real-cookie',
notify_config: {
webhook_url: 'https://example.invalid/hook/real',
enabled: true,
},
nickname: 'old',
};
const incoming = {
cookie: '***REDACTED***',
notify_config: {
webhook_url: '***REDACTED***',
enabled: false,
},
nickname: 'new',
};
expect(mergeRedactedUpdate(incoming, existing)).toEqual({
cookie: 'real-cookie',
notify_config: {
webhook_url: 'https://example.invalid/hook/real',
enabled: false,
},
nickname: 'new',
});
});
it('redacts sensitive values inside JSON string system config values', () => {
const input = [
{
key: 'global_notify_config',
value: JSON.stringify({ channels: { webhook: { webhook_url: 'https://hook.invalid/real', token: 'real-token' } } }),
},
];
const output = redactSensitive(input);
const parsed = JSON.parse(output[0].value);
expect(parsed.channels.webhook.webhook_url).toBe('***REDACTED***');
expect(parsed.channels.webhook.token).toBe('***REDACTED***');
});
it('returns only safe public system config entries', () => {
const input = [
{ key: 'site_name', value: 'CloudSearch' },
{ key: 'site_disclaimer', value: 'For search only' },
{ key: 'tmdb_api_token', value: 'real-token' },
{ key: 'global_notify_config', value: JSON.stringify({ channels: { webhook: { webhook_url: 'https://hook.invalid/real' } } }) },
];
expect(publicSystemConfigs(input)).toEqual([
{ key: 'site_disclaimer', value: 'For search only' },
{ key: 'site_name', value: 'CloudSearch' },
]);
});
});
+87
View File
@@ -0,0 +1,87 @@
export const REDACTED_PLACEHOLDER = '***REDACTED***';
const SENSITIVE_FIELD_RE = /(cookie|password|password_hash|token|secret|authorization|webhook_url|access_token|refresh_token|redis_url|proxy_url)/i;
const SENSITIVE_CONFIG_KEY_RE = /(cookie|password|password_hash|token|secret|authorization|webhook_url|access_token|refresh_token|api[_-]?key|redis_url|proxy_url)/i;
const PUBLIC_SYSTEM_CONFIG_KEYS = new Set([
'site_name',
'site_logo',
'site_disclaimer',
'search_placeholder',
'search_fallback_image',
'whitelist_dirs',
'custom_footer',
]);
export interface SystemConfigLike {
key: string;
value: string;
description?: string;
updated_at?: string;
}
function shouldRedactField(key: string): boolean {
return SENSITIVE_FIELD_RE.test(key);
}
function shouldRedactConfigValue(obj: Record<string, any>): boolean {
return typeof obj.key === 'string' && SENSITIVE_CONFIG_KEY_RE.test(obj.key);
}
function redactJsonString(value: string): string {
const trimmed = value.trim();
if (!trimmed || !(trimmed.startsWith('{') || trimmed.startsWith('['))) return value;
try {
return JSON.stringify(redactSensitive(JSON.parse(value)));
} catch {
return value;
}
}
export function isRedactedPlaceholder(value: unknown): boolean {
return value === REDACTED_PLACEHOLDER;
}
export function redactSensitive(value: any): any {
if (Array.isArray(value)) return value.map(item => redactSensitive(item));
if (!value || typeof value !== 'object') return value;
const input = value as Record<string, any>;
const out: any = {};
const redactConfigValue = shouldRedactConfigValue(input);
for (const [key, val] of Object.entries(input)) {
if (key === 'value' && redactConfigValue) {
out[key] = val ? REDACTED_PLACEHOLDER : val;
} else if (key === 'value' && typeof val === 'string') {
out[key] = redactJsonString(val);
} else if (shouldRedactField(key)) {
out[key] = val ? REDACTED_PLACEHOLDER : val;
} else {
out[key] = redactSensitive(val);
}
}
return out;
}
export function mergeRedactedUpdate<T = any>(incoming: T, existing: any): T {
if (isRedactedPlaceholder(incoming)) return existing;
if (Array.isArray(incoming)) {
if (!Array.isArray(existing)) return incoming;
return incoming.map((item, index) => mergeRedactedUpdate(item, existing[index])) as T;
}
if (!incoming || typeof incoming !== 'object') return incoming;
const out: any = { ...(incoming as any) };
for (const [key, val] of Object.entries(incoming as any)) {
out[key] = mergeRedactedUpdate(val, existing?.[key]);
}
return out;
}
export function publicSystemConfigs(configs: SystemConfigLike[]): SystemConfigLike[] {
return configs
.filter(cfg => PUBLIC_SYSTEM_CONFIG_KEYS.has(cfg.key))
.map(cfg => ({ key: cfg.key, value: cfg.value, ...(cfg.description !== undefined ? { description: cfg.description } : {}) }))
.sort((a, b) => a.key.localeCompare(b.key));
}