dsh-m 0.9.76 → 0.9.77
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/core/primary-screenshots.js +67 -28
- package/package.json +1 -1
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { fetchTextLimited } from './httpx.js';
|
|
2
2
|
const MANIFEST_HOST = 'raw.githubusercontent.com';
|
|
3
|
+
/**
|
|
4
|
+
* 0.9.77 兜底腿(桌面机 raw 被墙实录):raw.githubusercontent.com 直连在部分网络下
|
|
5
|
+
* TLS 握手即被 reset,而 registry 清单自身早有 default-raw → default-jsdelivr 降级链、
|
|
6
|
+
* 社区目录有 jsdelivr/npmmirror/unpkg 三路由——截图 manifest 是全市场唯一单源抓取点,
|
|
7
|
+
* 症状为「列表/README/安装全正常,唯独精选详情无截图」。此处补齐同款第二腿:
|
|
8
|
+
* jsDelivr 的 GitHub 镜像,仅当 raw 抓取层失败(含 404/超时/host guard 拒绝)时启用。
|
|
9
|
+
*/
|
|
10
|
+
const MIRROR_HOST = 'cdn.jsdelivr.net';
|
|
3
11
|
const MANIFEST_FILE = 'screenshots.json';
|
|
4
12
|
const MAX_MANIFEST_BYTES = 64 * 1024;
|
|
5
13
|
const MAX_SCREENSHOTS = 8;
|
|
@@ -9,7 +17,8 @@ const OWNER_MAX = 39;
|
|
|
9
17
|
const REPO_MAX = 100;
|
|
10
18
|
const GITHUB_REPO_RE = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/;
|
|
11
19
|
const CONTROL_RE = /[\u0000-\u001f\u007f]/;
|
|
12
|
-
|
|
20
|
+
/** owner/repo 校验与归一(GITHUB_REPO_RE 与 registry 同款);非法输入返回 null(零抓取)。 */
|
|
21
|
+
function repoSlug(github) {
|
|
13
22
|
const value = typeof github === 'string' ? github.trim() : '';
|
|
14
23
|
if (!GITHUB_REPO_RE.test(value))
|
|
15
24
|
return null;
|
|
@@ -18,9 +27,25 @@ function manifestUrl(github) {
|
|
|
18
27
|
const repo = value.slice(slash + 1);
|
|
19
28
|
if (owner.length > OWNER_MAX || repo.length > REPO_MAX || repo === '.' || repo === '..')
|
|
20
29
|
return null;
|
|
21
|
-
return
|
|
30
|
+
return `${owner}/${repo}`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* raw 规范形(/HEAD/ = 默认分支)。它同时是 parseManifest 的路径解析基址:无论哪条腿供数,
|
|
34
|
+
* 返回的图片 URL 一律落在 *.githubusercontent.com——恰好通过客户端 safeScreenshots 图床白名单
|
|
35
|
+
* (main.jsx 只放行 github.com / *.githubusercontent.com),镜像腿若原样回 jsdelivr URL 会被
|
|
36
|
+
* 客户端整体过滤、白修。
|
|
37
|
+
*/
|
|
38
|
+
function rawManifestUrl(slug) {
|
|
39
|
+
return `https://${MANIFEST_HOST}/${slug}/HEAD/${MANIFEST_FILE}`;
|
|
22
40
|
}
|
|
23
|
-
|
|
41
|
+
/**
|
|
42
|
+
* jsDelivr 镜像形(@HEAD 与 raw /HEAD/ 同义,默认分支,实测可用)。镜像存在 CDN 缓存滞后
|
|
43
|
+
* (registry default-jsdelivr 同款取舍,可用 purge.jsdelivr.net 手动清缓存),截图属低频变更元数据,可接受。
|
|
44
|
+
*/
|
|
45
|
+
function mirrorManifestUrl(slug) {
|
|
46
|
+
return `https://${MIRROR_HOST}/gh/${slug}@HEAD/${MANIFEST_FILE}`;
|
|
47
|
+
}
|
|
48
|
+
function screenshotUrl(baseUrl, rawPath) {
|
|
24
49
|
if (typeof rawPath !== 'string')
|
|
25
50
|
return null;
|
|
26
51
|
const path = rawPath.trim();
|
|
@@ -35,10 +60,10 @@ function screenshotUrl(manifest, rawPath) {
|
|
|
35
60
|
const segments = path.split('/');
|
|
36
61
|
if (segments.some((part) => part === '' || part === '.' || part === '..'))
|
|
37
62
|
return null;
|
|
38
|
-
const base = new URL('.',
|
|
63
|
+
const base = new URL('.', baseUrl);
|
|
39
64
|
return new URL(segments.map((part) => encodeURIComponent(part)).join('/'), base).toString();
|
|
40
65
|
}
|
|
41
|
-
function parseManifest(text,
|
|
66
|
+
function parseManifest(text, baseUrl) {
|
|
42
67
|
let parsed;
|
|
43
68
|
try {
|
|
44
69
|
parsed = JSON.parse(text);
|
|
@@ -50,14 +75,14 @@ function parseManifest(text, manifestUrl) {
|
|
|
50
75
|
return [];
|
|
51
76
|
const result = [];
|
|
52
77
|
for (const path of parsed) {
|
|
53
|
-
const url = screenshotUrl(
|
|
78
|
+
const url = screenshotUrl(baseUrl, path);
|
|
54
79
|
if (!url)
|
|
55
80
|
return [];
|
|
56
81
|
result.push(url);
|
|
57
82
|
}
|
|
58
83
|
return result;
|
|
59
84
|
}
|
|
60
|
-
function assertManifestHost(raw, isRedirectHop) {
|
|
85
|
+
function assertManifestHost(raw, isRedirectHop, allowedHost) {
|
|
61
86
|
let url;
|
|
62
87
|
try {
|
|
63
88
|
url = new URL(raw);
|
|
@@ -65,14 +90,31 @@ function assertManifestHost(raw, isRedirectHop) {
|
|
|
65
90
|
catch {
|
|
66
91
|
throw new Error(isRedirectHop ? 'screenshot manifest redirect URL is invalid' : 'invalid screenshot manifest request URL');
|
|
67
92
|
}
|
|
68
|
-
if (url.protocol !== 'https:' || url.hostname !==
|
|
93
|
+
if (url.protocol !== 'https:' || url.hostname !== allowedHost || url.username || url.password || url.port) {
|
|
69
94
|
throw new Error(isRedirectHop
|
|
70
|
-
?
|
|
71
|
-
:
|
|
95
|
+
? `screenshot manifest redirects must remain on ${allowedHost}`
|
|
96
|
+
: `screenshot manifest request URL must be https://${allowedHost}`);
|
|
72
97
|
}
|
|
73
98
|
}
|
|
99
|
+
/** 单腿抓取:每腿独立逐跳 host guard(初始跳 + 重定向都不许离开本腿源),5s / 64KB 上限逐腿独立生效。 */
|
|
100
|
+
async function fetchManifestLeg(fetchText, url, allowedHost) {
|
|
101
|
+
let hop = 0;
|
|
102
|
+
return fetchText(url, {
|
|
103
|
+
timeoutMs: 5_000,
|
|
104
|
+
maxBytes: MAX_MANIFEST_BYTES,
|
|
105
|
+
headers: { accept: 'application/json' },
|
|
106
|
+
onRequest: (hopUrl) => {
|
|
107
|
+
// 逐跳 host guard;错误文案区分初始跳与重定向跳,避免调试误导(评审建议落盘)。
|
|
108
|
+
const isRedirectHop = hop > 0;
|
|
109
|
+
hop += 1;
|
|
110
|
+
assertManifestHost(hopUrl, isRedirectHop, allowedHost);
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
74
114
|
/**
|
|
75
115
|
* Read an author-owned root screenshots.json without consulting Community Catalog.
|
|
116
|
+
* 两腿链:raw 抓取层失败 → jsDelivr 镜像;两腿全败 → 空图库。解析层的「空」是权威结果
|
|
117
|
+
* (两腿读的是同一 repo 文件,镜像不会有不同内容),不再触发兜底。
|
|
76
118
|
* All network access is bounded, and a rejected/empty manifest degrades to no gallery.
|
|
77
119
|
*/
|
|
78
120
|
export function createPrimaryScreenshotReader(options = {}) {
|
|
@@ -81,10 +123,11 @@ export function createPrimaryScreenshotReader(options = {}) {
|
|
|
81
123
|
const cache = new Map();
|
|
82
124
|
const flights = new Map();
|
|
83
125
|
return async (github) => {
|
|
84
|
-
const
|
|
85
|
-
if (!
|
|
126
|
+
const slug = repoSlug(github);
|
|
127
|
+
if (!slug)
|
|
86
128
|
return [];
|
|
87
|
-
const
|
|
129
|
+
const rawUrl = rawManifestUrl(slug);
|
|
130
|
+
const key = rawUrl.toLowerCase();
|
|
88
131
|
const timestamp = now();
|
|
89
132
|
for (const [cachedKey, value] of cache) {
|
|
90
133
|
if (value.expiresAt <= timestamp)
|
|
@@ -98,24 +141,20 @@ export function createPrimaryScreenshotReader(options = {}) {
|
|
|
98
141
|
return [...(await pending)];
|
|
99
142
|
const flight = (async () => {
|
|
100
143
|
let screenshots = [];
|
|
101
|
-
let hop = 0;
|
|
102
144
|
try {
|
|
103
|
-
const text = await fetchText
|
|
104
|
-
|
|
105
|
-
maxBytes: MAX_MANIFEST_BYTES,
|
|
106
|
-
headers: { accept: 'application/json' },
|
|
107
|
-
onRequest: (hopUrl) => {
|
|
108
|
-
// 逐跳 host guard;错误文案区分初始跳与重定向跳,避免调试误导(评审建议落盘)。
|
|
109
|
-
const isRedirectHop = hop > 0;
|
|
110
|
-
hop += 1;
|
|
111
|
-
assertManifestHost(hopUrl, isRedirectHop);
|
|
112
|
-
},
|
|
113
|
-
});
|
|
114
|
-
screenshots = parseManifest(text, url);
|
|
145
|
+
const text = await fetchManifestLeg(fetchText, rawUrl, MANIFEST_HOST);
|
|
146
|
+
screenshots = parseManifest(text, rawUrl);
|
|
115
147
|
}
|
|
116
148
|
catch {
|
|
117
|
-
|
|
118
|
-
|
|
149
|
+
try {
|
|
150
|
+
const mirrorUrl = mirrorManifestUrl(slug);
|
|
151
|
+
const text = await fetchManifestLeg(fetchText, mirrorUrl, MIRROR_HOST);
|
|
152
|
+
screenshots = parseManifest(text, rawUrl);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Images are optional metadata: a missing/unavailable manifest must not block detail/install.
|
|
156
|
+
screenshots = [];
|
|
157
|
+
}
|
|
119
158
|
}
|
|
120
159
|
cache.set(key, {
|
|
121
160
|
screenshots,
|