@chatcode/cco-market 1.45.1
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/LICENSE +21 -0
- package/README.md +146 -0
- package/README.zh.md +143 -0
- package/UPDATE-API-V1.md +132 -0
- package/client/client.js +11534 -0
- package/cordis.patch.yml +5 -0
- package/lib/accelerate.js +184 -0
- package/lib/agents.js +36 -0
- package/lib/backup.js +572 -0
- package/lib/catalog-local-match.js +126 -0
- package/lib/catalog-npm.js +111 -0
- package/lib/changelog.js +242 -0
- package/lib/channels.js +64 -0
- package/lib/check.js +1067 -0
- package/lib/compatibility.js +198 -0
- package/lib/diagnostics.js +49 -0
- package/lib/discovery-compatibility.js +260 -0
- package/lib/dsh-cli.js +949 -0
- package/lib/dsh-install.js +108 -0
- package/lib/gist.js +352 -0
- package/lib/groups.js +97 -0
- package/lib/home-paths.js +42 -0
- package/lib/hot.js +519 -0
- package/lib/http.js +39 -0
- package/lib/index.js +97 -0
- package/lib/install.js +387 -0
- package/lib/log.js +197 -0
- package/lib/ndjson.js +154 -0
- package/lib/net.js +98 -0
- package/lib/order.js +284 -0
- package/lib/patch.js +521 -0
- package/lib/pnpm-compat.js +490 -0
- package/lib/presets.js +278 -0
- package/lib/profile.js +911 -0
- package/lib/region-probe.js +93 -0
- package/lib/regions.js +253 -0
- package/lib/registry.js +204 -0
- package/lib/restart.js +373 -0
- package/lib/routes.js +4372 -0
- package/lib/settings.js +109 -0
- package/lib/skill-market/api.js +119 -0
- package/lib/skill-market/config.js +29 -0
- package/lib/skill-market/installer.js +352 -0
- package/lib/skill-market/runtime.js +13 -0
- package/lib/skill-market/tui.js +119 -0
- package/lib/skill-market/types.js +1 -0
- package/lib/skill-market/web-routes.js +123 -0
- package/lib/snapshot.js +500 -0
- package/lib/source-migration.js +54 -0
- package/lib/sources.js +596 -0
- package/lib/store.js +91 -0
- package/lib/themes.js +102 -0
- package/lib/trial.js +116 -0
- package/lib/types/accelerate.d.ts +85 -0
- package/lib/types/agents.d.ts +22 -0
- package/lib/types/backup.d.ts +131 -0
- package/lib/types/catalog-local-match.d.ts +50 -0
- package/lib/types/catalog-npm.d.ts +47 -0
- package/lib/types/changelog.d.ts +102 -0
- package/lib/types/channels.d.ts +56 -0
- package/lib/types/check.d.ts +266 -0
- package/lib/types/compatibility.d.ts +64 -0
- package/lib/types/diagnostics.d.ts +29 -0
- package/lib/types/discovery-compatibility.d.ts +80 -0
- package/lib/types/dsh-cli.d.ts +324 -0
- package/lib/types/dsh-install.d.ts +46 -0
- package/lib/types/gist.d.ts +53 -0
- package/lib/types/groups.d.ts +34 -0
- package/lib/types/home-paths.d.ts +16 -0
- package/lib/types/hot.d.ts +221 -0
- package/lib/types/http.d.ts +12 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/install.d.ts +172 -0
- package/lib/types/log.d.ts +43 -0
- package/lib/types/ndjson.d.ts +52 -0
- package/lib/types/net.d.ts +58 -0
- package/lib/types/order.d.ts +100 -0
- package/lib/types/patch.d.ts +129 -0
- package/lib/types/pnpm-compat.d.ts +93 -0
- package/lib/types/presets.d.ts +90 -0
- package/lib/types/profile.d.ts +253 -0
- package/lib/types/region-probe.d.ts +50 -0
- package/lib/types/regions.d.ts +122 -0
- package/lib/types/registry.d.ts +86 -0
- package/lib/types/restart.d.ts +194 -0
- package/lib/types/routes.d.ts +67 -0
- package/lib/types/settings.d.ts +78 -0
- package/lib/types/skill-market/api.d.ts +10 -0
- package/lib/types/skill-market/config.d.ts +3 -0
- package/lib/types/skill-market/installer.d.ts +23 -0
- package/lib/types/skill-market/runtime.d.ts +10 -0
- package/lib/types/skill-market/tui.d.ts +4 -0
- package/lib/types/skill-market/types.d.ts +71 -0
- package/lib/types/skill-market/web-routes.d.ts +19 -0
- package/lib/types/snapshot.d.ts +93 -0
- package/lib/types/source-migration.d.ts +11 -0
- package/lib/types/sources.d.ts +216 -0
- package/lib/types/store.d.ts +30 -0
- package/lib/types/themes.d.ts +40 -0
- package/lib/types/trial.d.ts +61 -0
- package/lib/types/update-api-v1.d.ts +66 -0
- package/lib/types/updates.d.ts +123 -0
- package/lib/types/verify.d.ts +139 -0
- package/lib/update-api-v1.js +215 -0
- package/lib/updates.js +361 -0
- package/lib/verify.js +453 -0
- package/package.json +121 -0
- package/src/accelerate.ts +213 -0
- package/src/agents.ts +43 -0
- package/src/backup.ts +583 -0
- package/src/catalog-local-match.ts +144 -0
- package/src/catalog-npm.ts +120 -0
- package/src/changelog.ts +282 -0
- package/src/channels.ts +70 -0
- package/src/check.ts +1239 -0
- package/src/client/CommentsModal.tsx +119 -0
- package/src/client/Diagnostics.tsx +907 -0
- package/src/client/ErrorBoundary.tsx +111 -0
- package/src/client/InstallToast.tsx +31 -0
- package/src/client/Market.module.css +830 -0
- package/src/client/MarketSection.tsx +5301 -0
- package/src/client/OperationsPanel.tsx +365 -0
- package/src/client/SettingsCard.tsx +621 -0
- package/src/client/SkillMarket.module.css +21 -0
- package/src/client/SkillMarketSection.tsx +162 -0
- package/src/client/comments.ts +54 -0
- package/src/client/globals.d.ts +13 -0
- package/src/client/index.ts +180 -0
- package/src/client/locales.ts +1112 -0
- package/src/client/market-data.ts +1321 -0
- package/src/client/operations.ts +201 -0
- package/src/client/preset-panel.tsx +263 -0
- package/src/client/primitives.d.ts +140 -0
- package/src/client/self-check.ts +147 -0
- package/src/client/snapshot-panel.tsx +244 -0
- package/src/compatibility.ts +237 -0
- package/src/diagnostics.ts +84 -0
- package/src/discovery-compatibility.ts +315 -0
- package/src/dsh-cli.ts +1126 -0
- package/src/dsh-install.ts +118 -0
- package/src/gist.ts +362 -0
- package/src/groups.ts +111 -0
- package/src/home-paths.ts +53 -0
- package/src/hot.ts +628 -0
- package/src/http.ts +41 -0
- package/src/index.ts +128 -0
- package/src/install.ts +420 -0
- package/src/log.ts +206 -0
- package/src/ndjson.ts +185 -0
- package/src/net.ts +106 -0
- package/src/order.ts +303 -0
- package/src/patch.ts +522 -0
- package/src/pnpm-compat.ts +527 -0
- package/src/presets.ts +344 -0
- package/src/profile.ts +940 -0
- package/src/region-probe.ts +97 -0
- package/src/regions.ts +310 -0
- package/src/registry.ts +250 -0
- package/src/restart.ts +396 -0
- package/src/routes.ts +4506 -0
- package/src/settings.ts +141 -0
- package/src/skill-market/api.ts +130 -0
- package/src/skill-market/config.ts +32 -0
- package/src/skill-market/installer.ts +337 -0
- package/src/skill-market/runtime.ts +14 -0
- package/src/skill-market/tui.ts +157 -0
- package/src/skill-market/types.ts +80 -0
- package/src/skill-market/web-routes.ts +132 -0
- package/src/snapshot.ts +532 -0
- package/src/source-migration.ts +61 -0
- package/src/sources.ts +565 -0
- package/src/store.ts +89 -0
- package/src/themes.ts +125 -0
- package/src/trial.ts +156 -0
- package/src/update-api-v1.ts +277 -0
- package/src/updates.ts +400 -0
- package/src/verify.ts +492 -0
package/lib/backup.js
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable profile backups: configuration only, never installed packages.
|
|
3
|
+
*
|
|
4
|
+
* The profile directory is plain user data — aside from package.json it can
|
|
5
|
+
* hold API keys (config.toml), tokens, or the WebDAV password when stored
|
|
6
|
+
* server-side. Backups therefore behave like `dsh export` and carry the same
|
|
7
|
+
* credential-warning disclaimer in the UI (review #63).
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
10
|
+
import { lookup } from 'node:dns/promises';
|
|
11
|
+
import { request as httpsRequest } from 'node:https';
|
|
12
|
+
import { isIP } from 'node:net';
|
|
13
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
14
|
+
import { profileDir } from './profile.js';
|
|
15
|
+
export const BACKUP_FORMAT = 'dsh-profile-backup';
|
|
16
|
+
export const MAX_BACKUP_BYTES = 2 * 1024 * 1024;
|
|
17
|
+
const MAX_FILES = 256;
|
|
18
|
+
const SKIP_NAMES = new Set(['node_modules', '.dsh-market', '.git', 'pnpm-lock.yaml']);
|
|
19
|
+
/** File names that routinely contain credentials (backup exports).
|
|
20
|
+
* Values are never masked in place — the export is one-to-one for
|
|
21
|
+
* faithful restores — but presence is surfaced by the UI warning. */
|
|
22
|
+
export const SECRET_FILE_HINTS = /(^|\/)(config\.toml|\.env(\.\w+)?|secrets?\.\w+t?j?s?o?n|pnpm-workspace\.yaml)$/i;
|
|
23
|
+
/** Count of exported files whose names look like they carry credentials. */
|
|
24
|
+
export function secretFileCount(profile, explicitDir) {
|
|
25
|
+
let count = 0;
|
|
26
|
+
for (const path of profileFiles(explicitDir ?? profileDir(profile))) {
|
|
27
|
+
if (SECRET_FILE_HINTS.test(path))
|
|
28
|
+
count += 1;
|
|
29
|
+
}
|
|
30
|
+
return count;
|
|
31
|
+
}
|
|
32
|
+
function profileFiles(root, dir = root) {
|
|
33
|
+
const files = [];
|
|
34
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
35
|
+
// Any .bak marker, not just the numeric-suffixed one this codebase
|
|
36
|
+
// writes. Recovery and the host's own repair paths leave other shapes —
|
|
37
|
+
// `package.json.bak-asm`, `cordis.patch.yml.rp-merged.bak` — and a
|
|
38
|
+
// backup that carried them restored them too, so the profile came back
|
|
39
|
+
// with the wreckage that made it need repairing (#205 by @Rudyy898).
|
|
40
|
+
if (SKIP_NAMES.has(entry.name) || /\.bak\b/.test(entry.name))
|
|
41
|
+
continue;
|
|
42
|
+
const path = resolve(dir, entry.name);
|
|
43
|
+
if (entry.isSymbolicLink())
|
|
44
|
+
continue;
|
|
45
|
+
if (entry.isDirectory())
|
|
46
|
+
files.push(...profileFiles(root, path));
|
|
47
|
+
else if (entry.isFile())
|
|
48
|
+
files.push(relative(root, path).split(sep).join('/'));
|
|
49
|
+
if (files.length > MAX_FILES)
|
|
50
|
+
throw new Error(`profile has more than ${MAX_FILES} configuration files`);
|
|
51
|
+
}
|
|
52
|
+
return files;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Serialize every profile file except dependencies, lock state, and market
|
|
56
|
+
* cache — or, with {@link BackupOptions.includeDeps}, only the manifest with
|
|
57
|
+
* the selected plugins (plus, optionally, the other config files).
|
|
58
|
+
*/
|
|
59
|
+
export function createProfileBackup(profile, explicitDir, opts) {
|
|
60
|
+
const root = resolve(explicitDir ?? profileDir(profile));
|
|
61
|
+
const manifestFile = resolve(root, 'package.json');
|
|
62
|
+
if (!existsSync(manifestFile))
|
|
63
|
+
throw new Error('profile package.json is missing');
|
|
64
|
+
const manifest = JSON.parse(readFileSync(manifestFile, 'utf8'));
|
|
65
|
+
if (opts?.includeDeps !== undefined) {
|
|
66
|
+
const include = new Set(opts.includeDeps);
|
|
67
|
+
if (include.size === 0)
|
|
68
|
+
throw new Error('no plugins selected');
|
|
69
|
+
const dependencies = manifest.dependencies === null || typeof manifest.dependencies !== 'object' || Array.isArray(manifest.dependencies)
|
|
70
|
+
? {}
|
|
71
|
+
: manifest.dependencies;
|
|
72
|
+
const filteredDeps = {};
|
|
73
|
+
for (const [name, spec] of Object.entries(dependencies))
|
|
74
|
+
if (include.has(name))
|
|
75
|
+
filteredDeps[name] = spec;
|
|
76
|
+
const dsh = manifest.dsh === null || typeof manifest.dsh !== 'object' || Array.isArray(manifest.dsh)
|
|
77
|
+
? undefined
|
|
78
|
+
: manifest.dsh;
|
|
79
|
+
const profileBlock = dsh?.profile === null || typeof dsh?.profile !== 'object' || Array.isArray(dsh?.profile)
|
|
80
|
+
? undefined
|
|
81
|
+
: dsh.profile;
|
|
82
|
+
const bundles = Array.isArray(profileBlock?.bundles) ? profileBlock.bundles : [];
|
|
83
|
+
const filteredBundles = bundles.filter((name) => typeof name === 'string' && include.has(name));
|
|
84
|
+
if (Object.keys(filteredDeps).length === 0 && filteredBundles.length === 0) {
|
|
85
|
+
throw new Error('none of the selected plugins are in this profile');
|
|
86
|
+
}
|
|
87
|
+
const filteredManifest = { ...manifest };
|
|
88
|
+
filteredManifest.dependencies = filteredDeps;
|
|
89
|
+
if (dsh !== undefined) {
|
|
90
|
+
filteredManifest.dsh = { ...dsh, profile: { ...(profileBlock ?? {}), bundles: filteredBundles } };
|
|
91
|
+
}
|
|
92
|
+
const files = [{ path: 'package.json', json: filteredManifest }];
|
|
93
|
+
if (opts.includeConfig === true) {
|
|
94
|
+
for (const path of profileFiles(root).sort()) {
|
|
95
|
+
if (path === 'package.json')
|
|
96
|
+
continue;
|
|
97
|
+
files.push({ path, lines: readFileSync(resolve(root, path), 'utf8').split(/\r?\n/) });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const partial = { format: BACKUP_FORMAT, version: 0.2, createdAt: new Date().toISOString(), profile, files };
|
|
101
|
+
if (Buffer.byteLength(JSON.stringify(partial)) > MAX_BACKUP_BYTES)
|
|
102
|
+
throw new Error('profile configuration is too large to back up');
|
|
103
|
+
return partial;
|
|
104
|
+
}
|
|
105
|
+
const files = profileFiles(root).sort().map((path) => {
|
|
106
|
+
const content = readFileSync(resolve(root, path), 'utf8');
|
|
107
|
+
return path === 'package.json'
|
|
108
|
+
? { path, json: JSON.parse(content) }
|
|
109
|
+
: { path, lines: content.split(/\r?\n/) };
|
|
110
|
+
});
|
|
111
|
+
if (!files.some(file => file.path === 'package.json'))
|
|
112
|
+
throw new Error('profile package.json is missing');
|
|
113
|
+
const backup = { format: BACKUP_FORMAT, version: 0.2, createdAt: new Date().toISOString(), profile, files };
|
|
114
|
+
if (Buffer.byteLength(JSON.stringify(backup)) > MAX_BACKUP_BYTES)
|
|
115
|
+
throw new Error('profile configuration is too large to back up');
|
|
116
|
+
return backup;
|
|
117
|
+
}
|
|
118
|
+
export function validatedBackup(value) {
|
|
119
|
+
if (value === null || typeof value !== 'object')
|
|
120
|
+
throw new Error('invalid backup');
|
|
121
|
+
const backup = value;
|
|
122
|
+
if (backup.format !== BACKUP_FORMAT || backup.version !== 0.2 || !Array.isArray(backup.files)) {
|
|
123
|
+
throw new Error('unsupported backup format');
|
|
124
|
+
}
|
|
125
|
+
if (backup.files.length > MAX_FILES)
|
|
126
|
+
throw new Error('invalid backup contents');
|
|
127
|
+
const files = [];
|
|
128
|
+
const paths = new Set();
|
|
129
|
+
for (const value of backup.files) {
|
|
130
|
+
if (value === null || typeof value !== 'object')
|
|
131
|
+
throw new Error('invalid backup contents');
|
|
132
|
+
const file = value;
|
|
133
|
+
const path = file.path;
|
|
134
|
+
if (typeof path !== 'string')
|
|
135
|
+
throw new Error('invalid backup contents');
|
|
136
|
+
if (path === '' || isAbsolute(path) || path.split(/[\\/]/).includes('..'))
|
|
137
|
+
throw new Error(`unsafe backup path: ${path}`);
|
|
138
|
+
const normalized = path.replaceAll('\\', '/');
|
|
139
|
+
if (normalized.split('/').some(part => SKIP_NAMES.has(part)))
|
|
140
|
+
throw new Error(`excluded backup path: ${path}`);
|
|
141
|
+
if (paths.has(normalized))
|
|
142
|
+
throw new Error(`duplicate backup path: ${path}`);
|
|
143
|
+
paths.add(normalized);
|
|
144
|
+
if (path === 'package.json') {
|
|
145
|
+
if (file.json === null || typeof file.json !== 'object' || Array.isArray(file.json))
|
|
146
|
+
throw new Error('backup package.json is invalid');
|
|
147
|
+
files.push({ path, json: file.json });
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
if (!Array.isArray(file.lines) || !file.lines.every(line => typeof line === 'string'))
|
|
151
|
+
throw new Error(`invalid file content: ${path}`);
|
|
152
|
+
files.push({ path, lines: file.lines });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (!files.some(file => file.path === 'package.json'))
|
|
156
|
+
throw new Error('invalid backup contents');
|
|
157
|
+
if (Buffer.byteLength(JSON.stringify(backup)) > MAX_BACKUP_BYTES)
|
|
158
|
+
throw new Error('backup is too large');
|
|
159
|
+
return { ...backup, files };
|
|
160
|
+
}
|
|
161
|
+
/** Atomically overwrite backed-up files and return a rollback for install failure. */
|
|
162
|
+
export function restoreProfileBackup(profile, value, explicitDir) {
|
|
163
|
+
const backup = validatedBackup(value);
|
|
164
|
+
const root = resolve(explicitDir ?? profileDir(profile));
|
|
165
|
+
const previous = new Map();
|
|
166
|
+
mkdirSync(root, { recursive: true });
|
|
167
|
+
const rollback = () => {
|
|
168
|
+
for (const [target, content] of previous) {
|
|
169
|
+
if (content === null)
|
|
170
|
+
rmSync(target, { force: true });
|
|
171
|
+
else
|
|
172
|
+
writeFileSync(target, content);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
try {
|
|
176
|
+
for (const file of backup.files) {
|
|
177
|
+
const { path } = file;
|
|
178
|
+
const target = resolve(root, path);
|
|
179
|
+
if (!target.startsWith(root + sep))
|
|
180
|
+
throw new Error(`unsafe backup path: ${path}`);
|
|
181
|
+
ensureSafeParent(root, dirname(target), path);
|
|
182
|
+
if (existsSync(target) && !lstatSync(target).isFile())
|
|
183
|
+
throw new Error(`backup path is not a file: ${path}`);
|
|
184
|
+
previous.set(target, existsSync(target) ? readFileSync(target) : null);
|
|
185
|
+
const temp = `${target}.dsh-restore-${String(process.pid)}`;
|
|
186
|
+
writeFileSync(temp, 'json' in file ? `${JSON.stringify(file.json, null, 2)}\n` : file.lines.join('\n'), 'utf8');
|
|
187
|
+
renameSync(temp, target);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
rollback();
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
files: previous.size,
|
|
196
|
+
rollback,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/** Create missing parents one level at a time and refuse existing symlinks. */
|
|
200
|
+
function ensureSafeParent(root, parent, backupPath) {
|
|
201
|
+
const relativeParent = relative(root, parent);
|
|
202
|
+
if (relativeParent === '')
|
|
203
|
+
return;
|
|
204
|
+
let current = root;
|
|
205
|
+
for (const part of relativeParent.split(sep)) {
|
|
206
|
+
current = resolve(current, part);
|
|
207
|
+
if (!existsSync(current)) {
|
|
208
|
+
mkdirSync(current);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const stat = lstatSync(current);
|
|
212
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
213
|
+
throw new Error(`unsafe backup path: ${backupPath}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function webdavRequest(url, username, password, method, body) {
|
|
217
|
+
const parsed = new URL(url);
|
|
218
|
+
// SSRF hardening (review #63): https-only by default, and always refuse
|
|
219
|
+
// private/link-local targets — so a same-origin script cannot tunnel into
|
|
220
|
+
// the host network. Besides the obvious metadata surfaces (169.254.169.254),
|
|
221
|
+
// loopback matters because other services on this machine may expose HTTP
|
|
222
|
+
// APIs of their own.
|
|
223
|
+
if (parsed.protocol === 'http:')
|
|
224
|
+
throw new Error('WebDAV requires an https:// URL');
|
|
225
|
+
if (parsed.protocol !== 'https:')
|
|
226
|
+
throw new Error('invalid WebDAV URL');
|
|
227
|
+
if (parsed.username !== '' || parsed.password !== '')
|
|
228
|
+
throw new Error('invalid WebDAV URL');
|
|
229
|
+
const address = await resolvePublicAddress(parsed.hostname);
|
|
230
|
+
const headers = { host: parsed.host };
|
|
231
|
+
if (body !== undefined) {
|
|
232
|
+
headers['content-type'] = 'application/json';
|
|
233
|
+
headers['content-length'] = String(Buffer.byteLength(body));
|
|
234
|
+
}
|
|
235
|
+
if (username !== '')
|
|
236
|
+
headers.authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
|
237
|
+
// Connect to the address that was checked above instead of resolving the
|
|
238
|
+
// hostname a second time. This closes the DNS-rebinding window between the
|
|
239
|
+
// allow/deny decision and the actual socket connection.
|
|
240
|
+
return await new Promise((resolveRequest, reject) => {
|
|
241
|
+
const originalHostname = unbracketedHostname(parsed.hostname);
|
|
242
|
+
const request = httpsRequest({
|
|
243
|
+
protocol: 'https:',
|
|
244
|
+
hostname: address.address,
|
|
245
|
+
family: address.family,
|
|
246
|
+
port: parsed.port === '' ? 443 : Number(parsed.port),
|
|
247
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
248
|
+
method,
|
|
249
|
+
headers,
|
|
250
|
+
servername: isIP(originalHostname) === 0 ? originalHostname : undefined,
|
|
251
|
+
signal: AbortSignal.timeout(30_000),
|
|
252
|
+
}, (response) => {
|
|
253
|
+
const chunks = [];
|
|
254
|
+
let size = 0;
|
|
255
|
+
const maxBytes = method === 'GET' ? MAX_BACKUP_BYTES : 64 * 1024;
|
|
256
|
+
response.once('error', reject);
|
|
257
|
+
const declared = Number(response.headers['content-length']);
|
|
258
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
259
|
+
response.destroy(new Error('WebDAV response is too large'));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
response.on('data', (chunk) => {
|
|
263
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
264
|
+
size += value.byteLength;
|
|
265
|
+
if (size > maxBytes) {
|
|
266
|
+
response.destroy(new Error('WebDAV response is too large'));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
chunks.push(value);
|
|
270
|
+
});
|
|
271
|
+
response.once('end', () => resolveRequest({
|
|
272
|
+
status: response.statusCode ?? 0,
|
|
273
|
+
body: Buffer.concat(chunks),
|
|
274
|
+
...(typeof response.headers.location === 'string' ? { location: response.headers.location } : {}),
|
|
275
|
+
}));
|
|
276
|
+
});
|
|
277
|
+
request.once('error', reject);
|
|
278
|
+
request.end(body);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Ancestor collection URLs of a WebDAV file, outermost first.
|
|
283
|
+
* `https://dav.example/a/b/x.json` → [`https://dav.example/a/`, `https://dav.example/a/b/`].
|
|
284
|
+
* The server root itself is never included — it always exists, and some
|
|
285
|
+
* providers reject MKCOL on it.
|
|
286
|
+
*/
|
|
287
|
+
export function webdavParentCollections(url) {
|
|
288
|
+
let parsed;
|
|
289
|
+
try {
|
|
290
|
+
parsed = new URL(url);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
const parts = parsed.pathname.split('/').filter(part => part !== '');
|
|
296
|
+
parts.pop(); // the file itself
|
|
297
|
+
const collections = [];
|
|
298
|
+
let path = '';
|
|
299
|
+
for (const part of parts) {
|
|
300
|
+
path += `/${part}`;
|
|
301
|
+
collections.push(`${parsed.origin}${path}/`);
|
|
302
|
+
}
|
|
303
|
+
return collections;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Upload the backup, creating missing parent collections first (#102).
|
|
307
|
+
*
|
|
308
|
+
* WebDAV servers do not create intermediate collections implicitly, so a PUT
|
|
309
|
+
* into a folder that does not exist yet fails — Jianguoyun answers 404, which
|
|
310
|
+
* read as "sync is broken" rather than "make the folder first". MKCOL on an
|
|
311
|
+
* existing collection answers 405, which is success for our purposes; any
|
|
312
|
+
* other failure is left to the PUT to report, since some providers restrict
|
|
313
|
+
* MKCOL while still accepting the upload.
|
|
314
|
+
*/
|
|
315
|
+
export async function uploadWebdav(url, username, password, backup) {
|
|
316
|
+
for (const collection of webdavParentCollections(url)) {
|
|
317
|
+
try {
|
|
318
|
+
await webdavRequest(collection, username, password, 'MKCOL');
|
|
319
|
+
}
|
|
320
|
+
catch { /* fall through: the PUT below reports the real problem */ }
|
|
321
|
+
}
|
|
322
|
+
const response = await webdavRequest(url, username, password, 'PUT', JSON.stringify(backup));
|
|
323
|
+
if (response.status < 200 || response.status >= 300) {
|
|
324
|
+
throw new Error(response.status === 404
|
|
325
|
+
? `WebDAV upload failed: HTTP 404 — the target folder does not exist and could not be created. Some providers (e.g. Jianguoyun) refuse files at the root: use a path inside a folder, e.g. https://dav.example.com/dsh/backup.json / 目标目录不存在且无法自动创建;部分服务商(如坚果云)不允许在根目录放文件,请使用形如 https://dav.example.com/dsh/backup.json 的子目录路径`
|
|
326
|
+
: `WebDAV upload failed: HTTP ${response.status}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/** Refuse non-global IPv4 targets, including metadata and carrier NAT ranges. */
|
|
330
|
+
export function isPublicIpv4(ip) {
|
|
331
|
+
const octets = ip.split('.').map(Number);
|
|
332
|
+
if (octets.length !== 4 || octets.some(part => !Number.isInteger(part) || part < 0 || part > 255))
|
|
333
|
+
return false;
|
|
334
|
+
const [a, b] = octets;
|
|
335
|
+
if (a === 0 || a === 10 || a === 127 || a >= 224)
|
|
336
|
+
return false;
|
|
337
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
338
|
+
return false;
|
|
339
|
+
if (a === 169 && b === 254)
|
|
340
|
+
return false;
|
|
341
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
342
|
+
return false;
|
|
343
|
+
if (a === 192 && (b === 0 || b === 168))
|
|
344
|
+
return false;
|
|
345
|
+
if (a === 198 && (b === 18 || b === 19))
|
|
346
|
+
return false;
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
/** Only public internet target hostnames are reachable for WebDAV. */
|
|
350
|
+
export function isPublicHostname(hostname) {
|
|
351
|
+
const lower = unbracketedHostname(hostname).toLowerCase();
|
|
352
|
+
const bare = lower.endsWith('.') ? lower.slice(0, -1) : lower;
|
|
353
|
+
return bare !== '' && bare !== 'localhost' && bare !== 'metadata.google.internal'
|
|
354
|
+
&& !bare.endsWith('.localhost') && !bare.endsWith('.internal') && !bare.endsWith('.local');
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Whether a WebDAV hostname may be fetched: public https targets only.
|
|
358
|
+
* Exported for tests.
|
|
359
|
+
*/
|
|
360
|
+
export function isPublicTarget(hostname) {
|
|
361
|
+
const bare = unbracketedHostname(hostname);
|
|
362
|
+
const family = isIP(bare);
|
|
363
|
+
if (family === 4)
|
|
364
|
+
return isPublicIpv4(bare);
|
|
365
|
+
if (family === 6)
|
|
366
|
+
return isPublicIpv6(bare);
|
|
367
|
+
return isPublicHostname(bare);
|
|
368
|
+
}
|
|
369
|
+
/** Only global-unicast IPv6 is usable for a server-side WebDAV connection. */
|
|
370
|
+
export function isPublicIpv6(ip) {
|
|
371
|
+
const bare = unbracketedHostname(ip);
|
|
372
|
+
if (isIP(bare) !== 6)
|
|
373
|
+
return false;
|
|
374
|
+
const first = Number.parseInt(bare.split(':', 1)[0] || '0', 16);
|
|
375
|
+
return Number.isFinite(first) && first >= 0x2000 && first <= 0x3fff;
|
|
376
|
+
}
|
|
377
|
+
function unbracketedHostname(hostname) {
|
|
378
|
+
return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
|
|
379
|
+
}
|
|
380
|
+
/** Resolve once, reject every unsafe answer, and return the address to pin. */
|
|
381
|
+
export async function resolvePublicAddress(hostname) {
|
|
382
|
+
const bare = unbracketedHostname(hostname);
|
|
383
|
+
const family = isIP(bare);
|
|
384
|
+
if (family === 4 || family === 6) {
|
|
385
|
+
if (!isPublicTarget(bare))
|
|
386
|
+
throw new Error('invalid WebDAV URL');
|
|
387
|
+
return { address: bare, family };
|
|
388
|
+
}
|
|
389
|
+
if (!isPublicHostname(bare))
|
|
390
|
+
throw new Error('invalid WebDAV URL');
|
|
391
|
+
const addresses = await lookup(bare, { all: true, verbatim: true });
|
|
392
|
+
if (addresses.length === 0 || addresses.some(({ address }) => !isPublicTarget(address))) {
|
|
393
|
+
throw new Error('invalid WebDAV URL');
|
|
394
|
+
}
|
|
395
|
+
const selected = addresses[0];
|
|
396
|
+
if (selected.family !== 4 && selected.family !== 6)
|
|
397
|
+
throw new Error('invalid WebDAV URL');
|
|
398
|
+
return { address: selected.address, family: selected.family };
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* How many redirects a download will follow before giving up. Providers use
|
|
402
|
+
* one hop (WebDAV endpoint -> signed CDN link); five tolerates a CDN's own
|
|
403
|
+
* indirection without letting two servers ping-pong forever.
|
|
404
|
+
*/
|
|
405
|
+
const MAX_REDIRECTS = 5;
|
|
406
|
+
export async function downloadWebdav(url, username, password) {
|
|
407
|
+
// Some providers answer a WebDAV GET with 302 to a time-limited signed CDN
|
|
408
|
+
// link while accepting PUT directly on the same path (#480 by @zhyx1996,
|
|
409
|
+
// measured against 123pan) — so upload worked and restore failed with
|
|
410
|
+
// "HTTP 302". Only GET follows: the upload path is direct on every
|
|
411
|
+
// provider seen, and replaying a PUT body across redirects is a can of
|
|
412
|
+
// worms nothing currently needs opened.
|
|
413
|
+
//
|
|
414
|
+
// Every hop goes back through webdavRequest, so the full SSRF gate —
|
|
415
|
+
// https-only, no credentials in the URL, resolvePublicAddress with the
|
|
416
|
+
// rebinding-safe direct-IP connect — is re-run per target, not only on the
|
|
417
|
+
// first one. A redirect is the server choosing the next URL; trusting it
|
|
418
|
+
// half as much as the user's own input would invert the threat model.
|
|
419
|
+
//
|
|
420
|
+
// Credentials do NOT cross origins. The signed link authorizes itself;
|
|
421
|
+
// forwarding Basic auth to whatever host the server named would hand the
|
|
422
|
+
// user's WebDAV password to a third party on the server's say-so.
|
|
423
|
+
let currentUrl = url;
|
|
424
|
+
let response = await webdavRequest(currentUrl, username, password, 'GET');
|
|
425
|
+
for (let hop = 0; hop < MAX_REDIRECTS; hop += 1) {
|
|
426
|
+
if (response.status !== 301 && response.status !== 302 && response.status !== 303
|
|
427
|
+
&& response.status !== 307 && response.status !== 308)
|
|
428
|
+
break;
|
|
429
|
+
if (response.location === undefined)
|
|
430
|
+
break;
|
|
431
|
+
const next = new URL(response.location, currentUrl);
|
|
432
|
+
const sameOrigin = next.origin === new URL(currentUrl).origin;
|
|
433
|
+
currentUrl = next.toString();
|
|
434
|
+
response = sameOrigin
|
|
435
|
+
? await webdavRequest(currentUrl, username, password, 'GET')
|
|
436
|
+
: await webdavRequest(currentUrl, '', '', 'GET');
|
|
437
|
+
}
|
|
438
|
+
if (response.status < 200 || response.status >= 300) {
|
|
439
|
+
throw new Error(response.status === 404
|
|
440
|
+
? 'WebDAV download failed: HTTP 404 — no backup at that path yet. Upload one first, and check the URL points at the backup FILE (…/dsh/backup.json), not its folder / 该路径下还没有备份文件。请先执行一次上传,并确认地址指向备份文件本身(…/dsh/backup.json)而不是目录'
|
|
441
|
+
: `WebDAV download failed: HTTP ${response.status}`);
|
|
442
|
+
}
|
|
443
|
+
// Validate strictly server-side so the fetch result is never a generic
|
|
444
|
+
// echo of an internal response: restore only accepts real backups.
|
|
445
|
+
const body = JSON.parse(response.body.toString('utf8'));
|
|
446
|
+
validatedBackup(body);
|
|
447
|
+
return body;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* The selected plugins' dependency specs and bundle entries from a backup's
|
|
451
|
+
* manifest. Only string specs survive — everything else in the manifest is
|
|
452
|
+
* untrusted and ignored (partial restore touches nothing but these).
|
|
453
|
+
*/
|
|
454
|
+
export function extractPluginSelection(backup, includeDeps) {
|
|
455
|
+
const manifest = backup.files.find(file => file.path === 'package.json' && 'json' in file);
|
|
456
|
+
if (manifest === undefined || !('json' in manifest))
|
|
457
|
+
throw new Error('backup has no package.json');
|
|
458
|
+
const include = new Set(includeDeps);
|
|
459
|
+
const json = manifest.json;
|
|
460
|
+
const dependencies = json.dependencies === null || typeof json.dependencies !== 'object' || Array.isArray(json.dependencies)
|
|
461
|
+
? {}
|
|
462
|
+
: json.dependencies;
|
|
463
|
+
const deps = {};
|
|
464
|
+
for (const [name, spec] of Object.entries(dependencies)) {
|
|
465
|
+
if (typeof spec === 'string' && include.has(name))
|
|
466
|
+
deps[name] = spec;
|
|
467
|
+
}
|
|
468
|
+
const dsh = json.dsh === null || typeof json.dsh !== 'object' || Array.isArray(json.dsh)
|
|
469
|
+
? undefined
|
|
470
|
+
: json.dsh;
|
|
471
|
+
const profileBlock = dsh?.profile === null || typeof dsh?.profile !== 'object' || Array.isArray(dsh?.profile)
|
|
472
|
+
? undefined
|
|
473
|
+
: dsh.profile;
|
|
474
|
+
const bundles = Array.isArray(profileBlock?.bundles) ? profileBlock.bundles : [];
|
|
475
|
+
return {
|
|
476
|
+
deps,
|
|
477
|
+
bundles: bundles.filter((name) => typeof name === 'string' && include.has(name)),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Merge a backup's manifest into the profile's current manifest so a restore
|
|
482
|
+
* never deletes plugins the target machine already has: current deps stay,
|
|
483
|
+
* backup specs win on name conflicts; bundle lists are unioned. When
|
|
484
|
+
* `selection` is given, only the selected plugins are merged in.
|
|
485
|
+
*/
|
|
486
|
+
/**
|
|
487
|
+
* Dependencies whose spec points at an absolute local path — `link:/Users/…`
|
|
488
|
+
* or `file:/home/…` (#205 by @Rudyy898).
|
|
489
|
+
*
|
|
490
|
+
* These are perfectly valid on the machine that wrote them and meaningless
|
|
491
|
+
* anywhere else, so a backup carrying one restores a manifest that `pnpm
|
|
492
|
+
* install` cannot satisfy: the path does not exist on the new machine and
|
|
493
|
+
* the whole restore fails on it.
|
|
494
|
+
*
|
|
495
|
+
* Reported, NOT rewritten. Turning `link:/Users/me/dev/plugin` into
|
|
496
|
+
* something portable means deciding where those files should live and
|
|
497
|
+
* whether to carry them at all, which is a design question and not
|
|
498
|
+
* something a restore should answer on the user's behalf. Naming them lets
|
|
499
|
+
* the operator decide before the install runs — which is the part that was
|
|
500
|
+
* missing.
|
|
501
|
+
*
|
|
502
|
+
* Relative `file:./vendor/x` specs are left alone: they resolve against the
|
|
503
|
+
* profile directory, which the restore recreates, so they travel fine.
|
|
504
|
+
*/
|
|
505
|
+
export function unportableDeps(dependencies) {
|
|
506
|
+
if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies))
|
|
507
|
+
return [];
|
|
508
|
+
const found = [];
|
|
509
|
+
for (const [name, raw] of Object.entries(dependencies)) {
|
|
510
|
+
if (typeof raw !== 'string')
|
|
511
|
+
continue;
|
|
512
|
+
const match = /^(?:link|file):(.+)$/i.exec(raw);
|
|
513
|
+
if (match === null)
|
|
514
|
+
continue;
|
|
515
|
+
let path = match[1];
|
|
516
|
+
try {
|
|
517
|
+
path = decodeURIComponent(path);
|
|
518
|
+
}
|
|
519
|
+
catch { /* keep the literal spec */ }
|
|
520
|
+
// POSIX absolute, Windows drive-letter, or UNC — every shape that names
|
|
521
|
+
// a location outside this profile.
|
|
522
|
+
if (/^\//.test(path) || /^[A-Za-z]:[\\/]/.test(path) || /^\\\\/.test(path))
|
|
523
|
+
found.push({ name, spec: raw });
|
|
524
|
+
}
|
|
525
|
+
return found;
|
|
526
|
+
}
|
|
527
|
+
export function mergeRestoreManifest(backupManifest, current, selection) {
|
|
528
|
+
const merged = { ...backupManifest };
|
|
529
|
+
const backupDeps = backupManifest.dependencies === null || typeof backupManifest.dependencies !== 'object' || Array.isArray(backupManifest.dependencies)
|
|
530
|
+
? {}
|
|
531
|
+
: backupManifest.dependencies;
|
|
532
|
+
const backupBundles = Array.isArray(backupManifest.dsh?.profile?.bundles)
|
|
533
|
+
? (backupManifest.dsh.profile.bundles)
|
|
534
|
+
: [];
|
|
535
|
+
const currentDeps = current.dependencies === null || typeof current.dependencies !== 'object' || Array.isArray(current.dependencies)
|
|
536
|
+
? {}
|
|
537
|
+
: current.dependencies;
|
|
538
|
+
const currentBundles = Array.isArray(current.dsh?.profile?.bundles)
|
|
539
|
+
? (current.dsh.profile.bundles)
|
|
540
|
+
: [];
|
|
541
|
+
// Deps: keep the target's, overlay the backup's (or only the selection).
|
|
542
|
+
const deps = { ...currentDeps };
|
|
543
|
+
const sourceDeps = selection !== undefined ? selection.deps : backupDeps;
|
|
544
|
+
for (const [name, spec] of Object.entries(sourceDeps))
|
|
545
|
+
deps[name] = spec;
|
|
546
|
+
merged.dependencies = deps;
|
|
547
|
+
// Bundles: union of target and backup (or selection), de-duplicated.
|
|
548
|
+
const bundles = new Set();
|
|
549
|
+
for (const name of currentBundles)
|
|
550
|
+
if (typeof name === 'string')
|
|
551
|
+
bundles.add(name);
|
|
552
|
+
const sourceBundles = selection !== undefined ? selection.bundles : backupBundles;
|
|
553
|
+
for (const name of sourceBundles)
|
|
554
|
+
if (typeof name === 'string')
|
|
555
|
+
bundles.add(name);
|
|
556
|
+
const currentDsh = current.dsh === null || typeof current.dsh !== 'object' || Array.isArray(current.dsh)
|
|
557
|
+
? undefined
|
|
558
|
+
: current.dsh;
|
|
559
|
+
const currentProfile = currentDsh?.profile === null || typeof currentDsh?.profile !== 'object' || Array.isArray(currentDsh?.profile)
|
|
560
|
+
? undefined
|
|
561
|
+
: currentDsh.profile;
|
|
562
|
+
const backupDsh = merged.dsh === null || typeof merged.dsh !== 'object' || Array.isArray(merged.dsh)
|
|
563
|
+
? undefined
|
|
564
|
+
: merged.dsh;
|
|
565
|
+
const backupProfile = backupDsh?.profile === null || typeof backupDsh?.profile !== 'object' || Array.isArray(backupDsh?.profile)
|
|
566
|
+
? undefined
|
|
567
|
+
: backupDsh.profile;
|
|
568
|
+
const profileMerged = { ...(backupProfile ?? {}), ...(currentProfile ?? {}), bundles: [...bundles] };
|
|
569
|
+
const dshMerged = { ...(backupDsh ?? {}), ...(currentDsh ?? {}), profile: profileMerged };
|
|
570
|
+
merged.dsh = dshMerged;
|
|
571
|
+
return merged;
|
|
572
|
+
}
|