@lemoncat7/dsh-ssh 1.3.3 → 1.3.9
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/ARCHITECTURE.md +11 -2
- package/README.md +45 -2
- package/lib/api.d.ts +2 -0
- package/lib/api.d.ts.map +1 -1
- package/lib/api.js +80 -0
- package/lib/api.js.map +1 -1
- package/lib/client-api.d.ts +33 -0
- package/lib/client-api.d.ts.map +1 -1
- package/lib/client-api.js +3 -0
- package/lib/client-api.js.map +1 -1
- package/lib/client.js +586 -55
- package/lib/client.js.map +4 -4
- package/lib/domain.d.ts +1 -0
- package/lib/domain.d.ts.map +1 -1
- package/lib/domain.js.map +1 -1
- package/lib/file-entry-sort.d.ts +13 -0
- package/lib/file-entry-sort.d.ts.map +1 -0
- package/lib/file-entry-sort.js +23 -0
- package/lib/file-entry-sort.js.map +1 -0
- package/lib/file-transfer-manager.d.ts +0 -1
- package/lib/file-transfer-manager.d.ts.map +1 -1
- package/lib/file-transfer-manager.js +7 -23
- package/lib/file-transfer-manager.js.map +1 -1
- package/lib/file-transfer-tools.js +9 -2
- package/lib/file-transfer-tools.js.map +1 -1
- package/lib/file-transfer-workspace.d.ts.map +1 -1
- package/lib/file-transfer-workspace.js +22 -4
- package/lib/file-transfer-workspace.js.map +1 -1
- package/lib/gist-sync.d.ts +153 -0
- package/lib/gist-sync.d.ts.map +1 -0
- package/lib/gist-sync.js +1043 -0
- package/lib/gist-sync.js.map +1 -0
- package/lib/github-device-auth.d.ts +30 -0
- package/lib/github-device-auth.d.ts.map +1 -0
- package/lib/github-device-auth.js +130 -0
- package/lib/github-device-auth.js.map +1 -0
- package/lib/github-http.d.ts +13 -0
- package/lib/github-http.d.ts.map +1 -0
- package/lib/github-http.js +115 -0
- package/lib/github-http.js.map +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +4 -1
- package/lib/index.js.map +1 -1
- package/lib/remote-tar-download.d.ts +5 -0
- package/lib/remote-tar-download.d.ts.map +1 -0
- package/lib/remote-tar-download.js +49 -0
- package/lib/remote-tar-download.js.map +1 -0
- package/lib/remote-tree-scan.d.ts +14 -0
- package/lib/remote-tree-scan.d.ts.map +1 -0
- package/lib/remote-tree-scan.js +27 -0
- package/lib/remote-tree-scan.js.map +1 -0
- package/lib/store.d.ts +2 -0
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +14 -1
- package/lib/store.js.map +1 -1
- package/lib/tools.js +2 -2
- package/lib/tools.js.map +1 -1
- package/package.json +5 -2
package/lib/gist-sync.js
ADDED
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, scrypt } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readFile, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { credentialKey } from '@deepseek-ai/dsh-credentials';
|
|
5
|
+
import { normalizeCredentialEntryDraft, normalizeFtpProfileDraft, normalizeProfileDraft, normalizeProxyEntryDraft, normalizeRemoteProjectDraft, } from './domain.js';
|
|
6
|
+
import { GitHubDeviceAuthService } from './github-device-auth.js';
|
|
7
|
+
import { createGitHubHttpTransport } from './github-http.js';
|
|
8
|
+
const MAIN_FILE = 'dsh-ssh.config.json';
|
|
9
|
+
const BACKUP_PREFIX = 'dsh-ssh.backup.';
|
|
10
|
+
const MAX_GIST_BYTES = 1_048_576;
|
|
11
|
+
const AUTO_SYNC_DELAY_MS = 3_000;
|
|
12
|
+
const AUTO_PULL_INTERVAL_MS = 5 * 60_000;
|
|
13
|
+
const GIST_CREDENTIAL_SCOPE = 'dsh-ssh-gist-sync';
|
|
14
|
+
const COLLECTION_NAMES = ['profiles', 'ftpProfiles', 'remoteProjects', 'credentialEntries', 'proxyEntries'];
|
|
15
|
+
export class GistTokenVault {
|
|
16
|
+
provider;
|
|
17
|
+
constructor(provider) {
|
|
18
|
+
this.provider = provider;
|
|
19
|
+
}
|
|
20
|
+
async configured() {
|
|
21
|
+
const record = await this.readRecord();
|
|
22
|
+
return { token: record.token !== undefined, encryption: record.encryptionPassphrase !== undefined };
|
|
23
|
+
}
|
|
24
|
+
async readToken() { return (await this.readRecord()).token; }
|
|
25
|
+
async readEncryptionPassphrase() { return (await this.readRecord()).encryptionPassphrase; }
|
|
26
|
+
async readRecord() {
|
|
27
|
+
const record = await this.provider.readRecord(credentialKey(GIST_CREDENTIAL_SCOPE, 'default'));
|
|
28
|
+
if (record === undefined)
|
|
29
|
+
return {};
|
|
30
|
+
if (record.kind !== 'grant')
|
|
31
|
+
throw new Error('Gist 同步凭据格式无效');
|
|
32
|
+
return parseGistCredentialPayload(record.payload);
|
|
33
|
+
}
|
|
34
|
+
async write(value) {
|
|
35
|
+
await this.provider.modifyRecord(credentialKey(GIST_CREDENTIAL_SCOPE, 'default'), async (current) => {
|
|
36
|
+
if (current !== undefined && current.kind !== 'grant')
|
|
37
|
+
throw new Error('Gist 同步凭据格式无效');
|
|
38
|
+
const previous = current === undefined ? {} : parseGistCredentialPayload(current.payload);
|
|
39
|
+
const payload = {
|
|
40
|
+
...previous,
|
|
41
|
+
...(value.token === undefined ? {} : { token: normalizeToken(value.token) }),
|
|
42
|
+
...(value.encryptionPassphrase === undefined ? {} : { encryptionPassphrase: normalizeEncryptionPassphrase(value.encryptionPassphrase) }),
|
|
43
|
+
};
|
|
44
|
+
return { kind: 'grant', payload };
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async delete() {
|
|
48
|
+
await this.provider.deleteRecord(credentialKey(GIST_CREDENTIAL_SCOPE, 'default'));
|
|
49
|
+
}
|
|
50
|
+
async clearToken() {
|
|
51
|
+
const key = credentialKey(GIST_CREDENTIAL_SCOPE, 'default');
|
|
52
|
+
const current = await this.provider.readRecord(key);
|
|
53
|
+
if (current === undefined)
|
|
54
|
+
return;
|
|
55
|
+
if (current.kind !== 'grant')
|
|
56
|
+
throw new Error('Gist 同步凭据格式无效');
|
|
57
|
+
const previous = parseGistCredentialPayload(current.payload);
|
|
58
|
+
if (previous.encryptionPassphrase === undefined) {
|
|
59
|
+
await this.provider.deleteRecord(key);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
await this.provider.modifyRecord(key, async () => ({
|
|
63
|
+
kind: 'grant', payload: { encryptionPassphrase: previous.encryptionPassphrase },
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export class GitHubGistClient {
|
|
68
|
+
token;
|
|
69
|
+
request;
|
|
70
|
+
constructor(token, request = fetch) {
|
|
71
|
+
this.token = token;
|
|
72
|
+
this.request = request;
|
|
73
|
+
}
|
|
74
|
+
async identify() {
|
|
75
|
+
const value = await this.json('https://api.github.com/user');
|
|
76
|
+
if (typeof value.login !== 'string')
|
|
77
|
+
throw new Error('GitHub 未返回有效账号');
|
|
78
|
+
return { login: value.login };
|
|
79
|
+
}
|
|
80
|
+
async get(id) {
|
|
81
|
+
return parseGist(await this.json(`https://api.github.com/gists/${normalizeGistId(id)}`));
|
|
82
|
+
}
|
|
83
|
+
async create(content) {
|
|
84
|
+
return parseGist(await this.json('https://api.github.com/gists', {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
body: JSON.stringify({ description: 'DSH SSH portable configuration', public: false, files: { [MAIN_FILE]: { content } } }),
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
async update(id, files) {
|
|
90
|
+
return parseGist(await this.json(`https://api.github.com/gists/${normalizeGistId(id)}`, {
|
|
91
|
+
method: 'PATCH', body: JSON.stringify({ files }),
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
async content(gist, filename = MAIN_FILE) {
|
|
95
|
+
const file = gist.files[filename];
|
|
96
|
+
if (file === undefined)
|
|
97
|
+
return undefined;
|
|
98
|
+
if (file.truncated !== true && typeof file.content === 'string')
|
|
99
|
+
return boundedContent(file.content);
|
|
100
|
+
if (typeof file.raw_url !== 'string' || !file.raw_url.startsWith('https://gist.githubusercontent.com/')) {
|
|
101
|
+
throw new Error(`Gist 文件 ${filename} 缺少可信下载地址`);
|
|
102
|
+
}
|
|
103
|
+
const response = await this.request(file.raw_url, { headers: this.headers(), signal: AbortSignal.timeout(15_000) });
|
|
104
|
+
if (!response.ok)
|
|
105
|
+
throw new Error(`读取 Gist 文件失败(HTTP ${response.status})`);
|
|
106
|
+
return boundedContent(await response.text());
|
|
107
|
+
}
|
|
108
|
+
async json(url, init = {}) {
|
|
109
|
+
const response = await this.request(url, {
|
|
110
|
+
...init,
|
|
111
|
+
headers: { ...this.headers(), ...(init.body === undefined ? {} : { 'content-type': 'application/json' }), ...init.headers },
|
|
112
|
+
signal: AbortSignal.timeout(15_000),
|
|
113
|
+
});
|
|
114
|
+
const value = await response.json().catch(() => undefined);
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
const detail = typeof value === 'object' && value !== null && typeof value.message === 'string'
|
|
117
|
+
? value.message
|
|
118
|
+
: `HTTP ${response.status}`;
|
|
119
|
+
throw new Error(`GitHub Gist 请求失败:${detail}`);
|
|
120
|
+
}
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
headers() {
|
|
124
|
+
return {
|
|
125
|
+
accept: 'application/vnd.github+json',
|
|
126
|
+
authorization: `Bearer ${this.token}`,
|
|
127
|
+
'user-agent': 'dsh-ssh-gist-sync',
|
|
128
|
+
'x-github-api-version': '2022-11-28',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export class GistSyncService {
|
|
133
|
+
store;
|
|
134
|
+
credentials;
|
|
135
|
+
vault;
|
|
136
|
+
metadataPath;
|
|
137
|
+
clientFactory;
|
|
138
|
+
githubHttp;
|
|
139
|
+
metadata;
|
|
140
|
+
persistQueue = Promise.resolve();
|
|
141
|
+
syncQueue = Promise.resolve(undefined);
|
|
142
|
+
unsubscribe;
|
|
143
|
+
autoTimer;
|
|
144
|
+
pullTimer;
|
|
145
|
+
applying = false;
|
|
146
|
+
running = false;
|
|
147
|
+
oauth;
|
|
148
|
+
constructor(store, credentials, vault, metadataPath, metadata, clientFactory, githubHttp) {
|
|
149
|
+
this.store = store;
|
|
150
|
+
this.credentials = credentials;
|
|
151
|
+
this.vault = vault;
|
|
152
|
+
this.metadataPath = metadataPath;
|
|
153
|
+
this.clientFactory = clientFactory;
|
|
154
|
+
this.githubHttp = githubHttp;
|
|
155
|
+
this.metadata = metadata;
|
|
156
|
+
this.oauth = new GitHubDeviceAuthService(() => this.metadata.settings.oauthClientId, async (token) => {
|
|
157
|
+
const identity = await this.clientFactory(token).identify();
|
|
158
|
+
await this.vault.write({ token });
|
|
159
|
+
this.metadata.githubLogin = identity.login;
|
|
160
|
+
delete this.metadata.lastError;
|
|
161
|
+
await this.persist();
|
|
162
|
+
return identity;
|
|
163
|
+
}, githubHttp.request);
|
|
164
|
+
}
|
|
165
|
+
static async open(store, credentials, vault, metadataPath, clientFactory) {
|
|
166
|
+
const metadata = await readMetadata(metadataPath);
|
|
167
|
+
const githubHttp = createGitHubHttpTransport(() => store.settings().githubProxy);
|
|
168
|
+
const factory = clientFactory ?? (token => new GitHubGistClient(token, githubHttp.request));
|
|
169
|
+
const service = new GistSyncService(store, credentials, vault, metadataPath, metadata, factory, githubHttp);
|
|
170
|
+
service.unsubscribe = store.subscribe((previous, next) => { service.onStoreChanged(previous, next); });
|
|
171
|
+
service.configureAutomaticSync();
|
|
172
|
+
if (metadata.settings.autoSync)
|
|
173
|
+
service.scheduleAutoSync(1_500);
|
|
174
|
+
return service;
|
|
175
|
+
}
|
|
176
|
+
async close() {
|
|
177
|
+
this.oauth.close();
|
|
178
|
+
this.unsubscribe?.();
|
|
179
|
+
this.unsubscribe = undefined;
|
|
180
|
+
if (this.autoTimer !== undefined)
|
|
181
|
+
clearTimeout(this.autoTimer);
|
|
182
|
+
if (this.pullTimer !== undefined)
|
|
183
|
+
clearInterval(this.pullTimer);
|
|
184
|
+
await this.persistQueue;
|
|
185
|
+
await this.syncQueue.catch(() => { });
|
|
186
|
+
await this.githubHttp.close();
|
|
187
|
+
}
|
|
188
|
+
async testNetwork() {
|
|
189
|
+
const response = await this.githubHttp.request('https://api.github.com/meta', {
|
|
190
|
+
headers: { accept: 'application/vnd.github+json', 'user-agent': 'dsh-ssh-github-network-test' },
|
|
191
|
+
signal: AbortSignal.timeout(15_000),
|
|
192
|
+
});
|
|
193
|
+
if (!response.ok)
|
|
194
|
+
throw new Error(`GitHub 网络测试失败(HTTP ${response.status})`);
|
|
195
|
+
await response.body?.cancel();
|
|
196
|
+
return { route: this.githubHttp.route() };
|
|
197
|
+
}
|
|
198
|
+
async view() {
|
|
199
|
+
const settings = this.metadata.settings;
|
|
200
|
+
const configured = await this.vault.configured();
|
|
201
|
+
return {
|
|
202
|
+
...settings,
|
|
203
|
+
tokenConfigured: configured.token,
|
|
204
|
+
encryptionConfigured: configured.encryption,
|
|
205
|
+
running: this.running,
|
|
206
|
+
...(this.metadata.lastSyncAt === undefined ? {} : { lastSyncAt: this.metadata.lastSyncAt }),
|
|
207
|
+
...(this.metadata.lastResult === undefined ? {} : { lastResult: this.metadata.lastResult }),
|
|
208
|
+
...(this.metadata.lastError === undefined ? {} : { lastError: this.metadata.lastError }),
|
|
209
|
+
...(settings.gistId === undefined ? {} : { gistUrl: `https://gist.github.com/${settings.gistId}` }),
|
|
210
|
+
...(this.metadata.githubLogin === undefined ? {} : { githubLogin: this.metadata.githubLogin }),
|
|
211
|
+
...(this.metadata.lastCloudVersion === undefined ? {} : { cloudVersion: this.metadata.lastCloudVersion }),
|
|
212
|
+
oauthAvailable: settings.oauthClientId !== undefined,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
async configure(value) {
|
|
216
|
+
const input = asRecord(value, 'Gist 同步设置');
|
|
217
|
+
const previousGistId = this.metadata.settings.gistId;
|
|
218
|
+
const settings = normalizeSettings(input.settings ?? input);
|
|
219
|
+
const credentialUpdate = {};
|
|
220
|
+
if (typeof input.token === 'string' && input.token.trim().length > 0)
|
|
221
|
+
credentialUpdate.token = input.token;
|
|
222
|
+
if (typeof input.encryptionPassphrase === 'string' && input.encryptionPassphrase.length > 0)
|
|
223
|
+
credentialUpdate.encryptionPassphrase = input.encryptionPassphrase;
|
|
224
|
+
if (Object.keys(credentialUpdate).length > 0) {
|
|
225
|
+
await this.vault.write(credentialUpdate);
|
|
226
|
+
if (credentialUpdate.token !== undefined)
|
|
227
|
+
delete this.metadata.githubLogin;
|
|
228
|
+
}
|
|
229
|
+
if (input.clearToken === true) {
|
|
230
|
+
await this.vault.clearToken();
|
|
231
|
+
delete this.metadata.githubLogin;
|
|
232
|
+
}
|
|
233
|
+
this.metadata.settings = settings;
|
|
234
|
+
if (previousGistId !== settings.gistId)
|
|
235
|
+
delete this.metadata.lastSyncedDigest;
|
|
236
|
+
delete this.metadata.lastError;
|
|
237
|
+
await this.persist();
|
|
238
|
+
this.configureAutomaticSync();
|
|
239
|
+
if (settings.autoSync)
|
|
240
|
+
this.scheduleAutoSync(500);
|
|
241
|
+
return this.view();
|
|
242
|
+
}
|
|
243
|
+
async testConnection() {
|
|
244
|
+
const client = await this.client();
|
|
245
|
+
const identity = await client.identify();
|
|
246
|
+
const gistId = this.metadata.settings.gistId;
|
|
247
|
+
if (gistId !== undefined) {
|
|
248
|
+
const gist = await client.get(gistId);
|
|
249
|
+
if (gist.version !== undefined)
|
|
250
|
+
this.metadata.lastCloudVersion = gist.version;
|
|
251
|
+
const content = await client.content(gist);
|
|
252
|
+
if (content !== undefined) {
|
|
253
|
+
const snapshot = parsePortableSnapshot(content);
|
|
254
|
+
await decryptSecretRecords(snapshot.secrets, await this.encryptionPassphrase());
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
this.metadata.githubLogin = identity.login;
|
|
258
|
+
await this.persist();
|
|
259
|
+
return { login: identity.login, ...(gistId === undefined ? {} : { gistId }) };
|
|
260
|
+
}
|
|
261
|
+
startOAuth() { return this.oauth.start(); }
|
|
262
|
+
pollOAuth(id) { return this.oauth.poll(id); }
|
|
263
|
+
async disconnectGitHub() {
|
|
264
|
+
await this.vault.clearToken();
|
|
265
|
+
delete this.metadata.githubLogin;
|
|
266
|
+
delete this.metadata.lastError;
|
|
267
|
+
await this.persist();
|
|
268
|
+
return this.view();
|
|
269
|
+
}
|
|
270
|
+
sync() {
|
|
271
|
+
const operation = this.syncQueue.catch(() => undefined).then(() => this.performSync());
|
|
272
|
+
this.syncQueue = operation;
|
|
273
|
+
return operation;
|
|
274
|
+
}
|
|
275
|
+
async performSync() {
|
|
276
|
+
this.running = true;
|
|
277
|
+
delete this.metadata.lastError;
|
|
278
|
+
try {
|
|
279
|
+
await this.persistQueue;
|
|
280
|
+
const client = await this.client();
|
|
281
|
+
const passphrase = await this.encryptionPassphrase();
|
|
282
|
+
const local = await createEncryptedPortableSnapshot(this.store.snapshot(), this.metadata.deviceId, this.metadata.tombstones, this.credentials, passphrase);
|
|
283
|
+
const localDigest = snapshotDigest(local);
|
|
284
|
+
const gistId = this.metadata.settings.gistId;
|
|
285
|
+
if (gistId === undefined) {
|
|
286
|
+
const created = await client.create(serializeSnapshot(local));
|
|
287
|
+
this.metadata.settings = { ...this.metadata.settings, gistId: created.id };
|
|
288
|
+
this.complete(local, localDigest, 'uploaded', created.version);
|
|
289
|
+
await this.persist();
|
|
290
|
+
return this.view();
|
|
291
|
+
}
|
|
292
|
+
let gist = await client.get(gistId);
|
|
293
|
+
const content = await client.content(gist);
|
|
294
|
+
if (content === undefined) {
|
|
295
|
+
gist = await client.update(gistId, { [MAIN_FILE]: { content: serializeSnapshot(local) } });
|
|
296
|
+
this.complete(local, localDigest, 'uploaded', gist.version);
|
|
297
|
+
await this.persist();
|
|
298
|
+
return this.view();
|
|
299
|
+
}
|
|
300
|
+
const remote = parsePortableSnapshot(content);
|
|
301
|
+
const remoteDigest = snapshotDigest(remote);
|
|
302
|
+
const isFreshEmptyDevice = this.metadata.lastSyncedDigest === undefined
|
|
303
|
+
&& !snapshotHasPortableData(local)
|
|
304
|
+
&& snapshotHasPortableData(remote);
|
|
305
|
+
const decision = isFreshEmptyDevice
|
|
306
|
+
? 'remote'
|
|
307
|
+
: resolveSyncDecision(localDigest, remoteDigest, this.metadata.lastSyncedDigest, this.metadata.settings.strategy);
|
|
308
|
+
if (decision === 'unchanged') {
|
|
309
|
+
this.complete(remote, remoteDigest, 'unchanged', gist.version);
|
|
310
|
+
}
|
|
311
|
+
else if (decision === 'remote') {
|
|
312
|
+
if (localDigest !== remoteDigest)
|
|
313
|
+
gist = await this.writeBackup(client, gist, local);
|
|
314
|
+
await this.apply(remote, passphrase);
|
|
315
|
+
this.complete(remote, remoteDigest, 'downloaded', gist.version);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
const result = decision === 'merge' ? mergePortableSnapshots(local, remote, this.metadata.deviceId) : local;
|
|
319
|
+
const resultDigest = snapshotDigest(result);
|
|
320
|
+
if (remoteDigest !== resultDigest) {
|
|
321
|
+
gist = await this.writeMain(client, gist, result, remote);
|
|
322
|
+
}
|
|
323
|
+
if (localDigest !== resultDigest)
|
|
324
|
+
await this.apply(result, passphrase);
|
|
325
|
+
this.complete(result, resultDigest, decision === 'merge' ? 'merged' : 'uploaded', gist.version);
|
|
326
|
+
}
|
|
327
|
+
await this.persist();
|
|
328
|
+
return this.view();
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
this.metadata.lastError = error instanceof Error ? error.message : String(error);
|
|
332
|
+
await this.persist().catch(() => { });
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
this.running = false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async client() {
|
|
340
|
+
const token = await this.vault.readToken();
|
|
341
|
+
if (token === undefined)
|
|
342
|
+
throw new Error('请先在设置中保存 GitHub Token');
|
|
343
|
+
return this.clientFactory(token);
|
|
344
|
+
}
|
|
345
|
+
async encryptionPassphrase() {
|
|
346
|
+
const passphrase = await this.vault.readEncryptionPassphrase();
|
|
347
|
+
if (passphrase === undefined)
|
|
348
|
+
throw new Error('请先在设置中保存同步加密密码');
|
|
349
|
+
return passphrase;
|
|
350
|
+
}
|
|
351
|
+
async writeMain(client, gist, result, previousRemote) {
|
|
352
|
+
const files = { [MAIN_FILE]: { content: serializeSnapshot(result) } };
|
|
353
|
+
this.addBackupFiles(files, gist, previousRemote);
|
|
354
|
+
return client.update(gist.id, files);
|
|
355
|
+
}
|
|
356
|
+
async writeBackup(client, gist, snapshot) {
|
|
357
|
+
const files = {};
|
|
358
|
+
this.addBackupFiles(files, gist, snapshot);
|
|
359
|
+
return Object.keys(files).length === 0 ? gist : client.update(gist.id, files);
|
|
360
|
+
}
|
|
361
|
+
addBackupFiles(files, gist, snapshot) {
|
|
362
|
+
const retention = this.metadata.settings.backupRetention;
|
|
363
|
+
const existing = Object.keys(gist.files).filter(name => name.startsWith(BACKUP_PREFIX)).sort().reverse();
|
|
364
|
+
if (retention > 0) {
|
|
365
|
+
const stamp = new Date().toISOString().replace(/[-:.TZ]/g, '');
|
|
366
|
+
const filename = `${BACKUP_PREFIX}${stamp}.${this.metadata.deviceId.slice(0, 8)}.${randomBytes(3).toString('hex')}.json`;
|
|
367
|
+
files[filename] = { content: serializeSnapshot(snapshot) };
|
|
368
|
+
for (const stale of existing.slice(Math.max(0, retention - 1)))
|
|
369
|
+
files[stale] = null;
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
for (const stale of existing)
|
|
373
|
+
files[stale] = null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
async apply(snapshot, passphrase) {
|
|
377
|
+
const secrets = await decryptSecretRecords(snapshot.secrets, passphrase);
|
|
378
|
+
const previous = this.store.snapshot();
|
|
379
|
+
this.applying = true;
|
|
380
|
+
try {
|
|
381
|
+
await this.store.update(state => {
|
|
382
|
+
state.profiles = structuredClone(snapshot.collections.profiles);
|
|
383
|
+
state.ftpProfiles = structuredClone(snapshot.collections.ftpProfiles);
|
|
384
|
+
state.remoteProjects = structuredClone(snapshot.collections.remoteProjects);
|
|
385
|
+
state.credentialEntries = structuredClone(snapshot.collections.credentialEntries);
|
|
386
|
+
state.proxyEntries = structuredClone(snapshot.collections.proxyEntries);
|
|
387
|
+
});
|
|
388
|
+
await this.replaceSecrets(previous, snapshot, secrets);
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
this.applying = false;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
async replaceSecrets(previous, snapshot, secrets) {
|
|
395
|
+
for (const id of uniqueIds(previous.profiles, snapshot.collections.profiles)) {
|
|
396
|
+
const profile = snapshot.collections.profiles.find(item => item.id === id);
|
|
397
|
+
const value = profile?.credentialId === undefined ? secrets.get(secretKey('ssh-profile', id)) : undefined;
|
|
398
|
+
if (value === undefined)
|
|
399
|
+
await this.credentials.delete(id);
|
|
400
|
+
else
|
|
401
|
+
await this.credentials.replace(id, value);
|
|
402
|
+
}
|
|
403
|
+
for (const id of uniqueIds(previous.ftpProfiles, snapshot.collections.ftpProfiles)) {
|
|
404
|
+
const profile = snapshot.collections.ftpProfiles.find(item => item.id === id);
|
|
405
|
+
const value = profile?.credentialId === undefined ? secrets.get(secretKey('ftp-profile', id)) : undefined;
|
|
406
|
+
if (value === undefined)
|
|
407
|
+
await this.credentials.deleteFtp(id);
|
|
408
|
+
else
|
|
409
|
+
await this.credentials.replaceFtp(id, value);
|
|
410
|
+
}
|
|
411
|
+
for (const id of uniqueIds(previous.credentialEntries, snapshot.collections.credentialEntries)) {
|
|
412
|
+
const value = secrets.get(secretKey('vault-entry', id));
|
|
413
|
+
if (value === undefined)
|
|
414
|
+
await this.credentials.deleteEntry(id);
|
|
415
|
+
else
|
|
416
|
+
await this.credentials.replaceEntry(id, value);
|
|
417
|
+
}
|
|
418
|
+
for (const id of uniqueIds(previous.proxyEntries, snapshot.collections.proxyEntries)) {
|
|
419
|
+
const value = secrets.get(secretKey('proxy-entry', id));
|
|
420
|
+
if (value === undefined)
|
|
421
|
+
await this.credentials.deleteProxyEntry(id);
|
|
422
|
+
else
|
|
423
|
+
await this.credentials.replaceProxyEntry(id, value);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
complete(snapshot, digest, result, cloudVersion) {
|
|
427
|
+
this.metadata.tombstones = structuredClone(snapshot.tombstones);
|
|
428
|
+
this.metadata.lastSyncedDigest = digest;
|
|
429
|
+
this.metadata.lastSyncAt = Date.now();
|
|
430
|
+
this.metadata.lastResult = result;
|
|
431
|
+
if (cloudVersion !== undefined)
|
|
432
|
+
this.metadata.lastCloudVersion = cloudVersion;
|
|
433
|
+
delete this.metadata.lastError;
|
|
434
|
+
}
|
|
435
|
+
onStoreChanged(previous, next) {
|
|
436
|
+
if (this.applying)
|
|
437
|
+
return;
|
|
438
|
+
let changed = false;
|
|
439
|
+
const now = Date.now();
|
|
440
|
+
for (const name of COLLECTION_NAMES) {
|
|
441
|
+
const previousIds = new Set(previous[name].map(item => item.id));
|
|
442
|
+
const nextIds = new Set(next[name].map(item => item.id));
|
|
443
|
+
for (const id of previousIds)
|
|
444
|
+
if (!nextIds.has(id)) {
|
|
445
|
+
this.metadata.tombstones[name][id] = now;
|
|
446
|
+
changed = true;
|
|
447
|
+
}
|
|
448
|
+
if (!changed && snapshotCollectionDigest(previous[name]) !== snapshotCollectionDigest(next[name]))
|
|
449
|
+
changed = true;
|
|
450
|
+
}
|
|
451
|
+
if (!changed)
|
|
452
|
+
return;
|
|
453
|
+
void this.persist();
|
|
454
|
+
if (this.metadata.settings.autoSync)
|
|
455
|
+
this.scheduleAutoSync();
|
|
456
|
+
}
|
|
457
|
+
scheduleAutoSync(delay = AUTO_SYNC_DELAY_MS) {
|
|
458
|
+
if (!this.metadata.settings.autoSync)
|
|
459
|
+
return;
|
|
460
|
+
if (this.autoTimer !== undefined)
|
|
461
|
+
clearTimeout(this.autoTimer);
|
|
462
|
+
this.autoTimer = setTimeout(() => {
|
|
463
|
+
this.autoTimer = undefined;
|
|
464
|
+
void this.sync().catch(() => { });
|
|
465
|
+
}, delay);
|
|
466
|
+
this.autoTimer.unref?.();
|
|
467
|
+
}
|
|
468
|
+
configureAutomaticSync() {
|
|
469
|
+
if (this.pullTimer !== undefined)
|
|
470
|
+
clearInterval(this.pullTimer);
|
|
471
|
+
this.pullTimer = undefined;
|
|
472
|
+
if (!this.metadata.settings.autoSync)
|
|
473
|
+
return;
|
|
474
|
+
this.pullTimer = setInterval(() => { void this.sync().catch(() => { }); }, AUTO_PULL_INTERVAL_MS);
|
|
475
|
+
this.pullTimer.unref?.();
|
|
476
|
+
}
|
|
477
|
+
persist() {
|
|
478
|
+
const value = structuredClone(this.metadata);
|
|
479
|
+
const operation = this.persistQueue.then(() => writeMetadata(this.metadataPath, value));
|
|
480
|
+
this.persistQueue = operation.catch(() => { });
|
|
481
|
+
return operation;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
export function createPortableSnapshot(state, deviceId, tombstones = emptyTombstones(), exportedAt = Date.now()) {
|
|
485
|
+
return {
|
|
486
|
+
schemaVersion: 1,
|
|
487
|
+
exportedAt,
|
|
488
|
+
sourceDeviceId: deviceId,
|
|
489
|
+
collections: {
|
|
490
|
+
profiles: sortItems(state.profiles),
|
|
491
|
+
ftpProfiles: sortItems(state.ftpProfiles),
|
|
492
|
+
remoteProjects: sortItems(state.remoteProjects),
|
|
493
|
+
credentialEntries: sortItems(state.credentialEntries),
|
|
494
|
+
proxyEntries: sortItems(state.proxyEntries),
|
|
495
|
+
},
|
|
496
|
+
tombstones: cloneTombstones(tombstones),
|
|
497
|
+
secrets: [],
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
export async function createEncryptedPortableSnapshot(state, deviceId, tombstones, credentials, passphrase, exportedAt = Date.now()) {
|
|
501
|
+
const snapshot = createPortableSnapshot(state, deviceId, tombstones, exportedAt);
|
|
502
|
+
const records = [];
|
|
503
|
+
for (const profile of state.profiles)
|
|
504
|
+
if (profile.credentialId === undefined) {
|
|
505
|
+
records.push({ scope: 'ssh-profile', id: profile.id, updatedAt: profile.updatedAt, value: await credentials.read(profile.id) });
|
|
506
|
+
}
|
|
507
|
+
for (const profile of state.ftpProfiles)
|
|
508
|
+
if (profile.credentialId === undefined) {
|
|
509
|
+
records.push({ scope: 'ftp-profile', id: profile.id, updatedAt: profile.updatedAt, value: await credentials.readFtp(profile.id) });
|
|
510
|
+
}
|
|
511
|
+
for (const entry of state.credentialEntries)
|
|
512
|
+
records.push({ scope: 'vault-entry', id: entry.id, updatedAt: entry.updatedAt, value: await credentials.readEntry(entry.id) });
|
|
513
|
+
for (const entry of state.proxyEntries)
|
|
514
|
+
records.push({ scope: 'proxy-entry', id: entry.id, updatedAt: entry.updatedAt, value: await credentials.readProxyEntry(entry.id) });
|
|
515
|
+
const salt = randomBytes(16);
|
|
516
|
+
const key = await deriveEncryptionKey(passphrase, salt);
|
|
517
|
+
snapshot.secrets = (await Promise.all(records.filter(item => Object.keys(item.value).length > 0).map(item => encryptSecretRecord(item, salt, key))))
|
|
518
|
+
.sort((left, right) => secretKey(left.scope, left.id).localeCompare(secretKey(right.scope, right.id)));
|
|
519
|
+
return snapshot;
|
|
520
|
+
}
|
|
521
|
+
export function parsePortableSnapshot(content) {
|
|
522
|
+
const value = JSON.parse(boundedContent(content));
|
|
523
|
+
const input = asRecord(value, 'Gist 配置');
|
|
524
|
+
if (input.schemaVersion !== 1)
|
|
525
|
+
throw new Error(`不支持的 Gist 配置版本:${String(input.schemaVersion)}`);
|
|
526
|
+
const collections = asRecord(input.collections, 'Gist 配置集合');
|
|
527
|
+
const result = {
|
|
528
|
+
schemaVersion: 1,
|
|
529
|
+
exportedAt: timestamp(input.exportedAt, 'exportedAt'),
|
|
530
|
+
sourceDeviceId: text(input.sourceDeviceId, 'sourceDeviceId', 8, 100),
|
|
531
|
+
collections: {
|
|
532
|
+
profiles: array(collections.profiles, 'profiles').map(parseProfile),
|
|
533
|
+
ftpProfiles: array(collections.ftpProfiles, 'ftpProfiles').map(parseFtpProfile),
|
|
534
|
+
remoteProjects: array(collections.remoteProjects, 'remoteProjects').map(parseRemoteProject),
|
|
535
|
+
credentialEntries: array(collections.credentialEntries, 'credentialEntries').map(parseCredentialEntry),
|
|
536
|
+
proxyEntries: array(collections.proxyEntries, 'proxyEntries').map(parseProxyEntry),
|
|
537
|
+
},
|
|
538
|
+
tombstones: parseTombstones(input.tombstones),
|
|
539
|
+
secrets: input.secrets === undefined ? [] : array(input.secrets, 'secrets').map(parseEncryptedSecretRecord).sort((a, b) => secretKey(a.scope, a.id).localeCompare(secretKey(b.scope, b.id))),
|
|
540
|
+
};
|
|
541
|
+
assertUniqueIds(result);
|
|
542
|
+
assertPortableReferences(result.collections);
|
|
543
|
+
return result;
|
|
544
|
+
}
|
|
545
|
+
export function mergePortableSnapshots(local, remote, deviceId, now = Date.now()) {
|
|
546
|
+
const tombstones = emptyTombstones();
|
|
547
|
+
const collections = {};
|
|
548
|
+
for (const name of COLLECTION_NAMES) {
|
|
549
|
+
const localItems = new Map(local.collections[name].map(item => [item.id, item]));
|
|
550
|
+
const remoteItems = new Map(remote.collections[name].map(item => [item.id, item]));
|
|
551
|
+
const deleted = { ...local.tombstones[name] };
|
|
552
|
+
for (const [id, deletedAt] of Object.entries(remote.tombstones[name]))
|
|
553
|
+
deleted[id] = Math.max(deleted[id] ?? 0, deletedAt);
|
|
554
|
+
tombstones[name] = deleted;
|
|
555
|
+
const ids = new Set([...localItems.keys(), ...remoteItems.keys(), ...Object.keys(deleted)]);
|
|
556
|
+
const merged = [];
|
|
557
|
+
for (const id of ids) {
|
|
558
|
+
const left = localItems.get(id);
|
|
559
|
+
const right = remoteItems.get(id);
|
|
560
|
+
const selected = selectNewest(left, right);
|
|
561
|
+
if (selected !== undefined && selected.updatedAt > (deleted[id] ?? 0))
|
|
562
|
+
merged.push(structuredClone(selected));
|
|
563
|
+
}
|
|
564
|
+
;
|
|
565
|
+
collections[name] = sortItems(merged);
|
|
566
|
+
}
|
|
567
|
+
const result = { schemaVersion: 1, exportedAt: now, sourceDeviceId: deviceId, collections, tombstones, secrets: [] };
|
|
568
|
+
result.secrets = mergeSecretRecords(local.secrets, remote.secrets, result);
|
|
569
|
+
repairReferences(result, now);
|
|
570
|
+
result.secrets = result.secrets.filter(record => secretOwnerExists(record, result.collections));
|
|
571
|
+
return result;
|
|
572
|
+
}
|
|
573
|
+
export function snapshotDigest(snapshot) {
|
|
574
|
+
const secrets = snapshot.secrets.map(record => ({ scope: record.scope, id: record.id, updatedAt: record.updatedAt, contentHash: record.contentHash }));
|
|
575
|
+
return createHash('sha256').update(stableJson({ collections: snapshot.collections, tombstones: snapshot.tombstones, secrets })).digest('hex');
|
|
576
|
+
}
|
|
577
|
+
export function resolveSyncDecision(localDigest, remoteDigest, baseDigest, strategy) {
|
|
578
|
+
if (localDigest === remoteDigest)
|
|
579
|
+
return 'unchanged';
|
|
580
|
+
if (baseDigest !== undefined) {
|
|
581
|
+
const localChanged = localDigest !== baseDigest;
|
|
582
|
+
const remoteChanged = remoteDigest !== baseDigest;
|
|
583
|
+
if (localChanged && !remoteChanged)
|
|
584
|
+
return 'local';
|
|
585
|
+
if (!localChanged && remoteChanged)
|
|
586
|
+
return 'remote';
|
|
587
|
+
}
|
|
588
|
+
return strategy === 'local-first' ? 'local' : strategy === 'cloud-first' ? 'remote' : 'merge';
|
|
589
|
+
}
|
|
590
|
+
function normalizeSettings(value) {
|
|
591
|
+
const input = asRecord(value, 'Gist 同步设置');
|
|
592
|
+
const strategy = input.strategy;
|
|
593
|
+
if (strategy !== 'smart' && strategy !== 'local-first' && strategy !== 'cloud-first')
|
|
594
|
+
throw new Error('同步策略无效');
|
|
595
|
+
const backupRetention = input.backupRetention;
|
|
596
|
+
if (!Number.isSafeInteger(backupRetention) || backupRetention < 0 || backupRetention > 50) {
|
|
597
|
+
throw new Error('备份保留数量必须是 0 到 50');
|
|
598
|
+
}
|
|
599
|
+
const gistId = input.gistId === undefined || input.gistId === null || input.gistId === '' ? undefined : normalizeGistId(input.gistId);
|
|
600
|
+
const oauthClientId = input.oauthClientId === undefined || input.oauthClientId === null || input.oauthClientId === '' ? undefined : normalizeOAuthClientId(input.oauthClientId);
|
|
601
|
+
return {
|
|
602
|
+
autoSync: input.autoSync === true, strategy, backupRetention: backupRetention,
|
|
603
|
+
...(gistId === undefined ? {} : { gistId }),
|
|
604
|
+
...(oauthClientId === undefined ? {} : { oauthClientId }),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
function defaultSettings() { return { autoSync: false, strategy: 'smart', backupRetention: 5 }; }
|
|
608
|
+
function emptyTombstones() {
|
|
609
|
+
return { profiles: {}, ftpProfiles: {}, remoteProjects: {}, credentialEntries: {}, proxyEntries: {} };
|
|
610
|
+
}
|
|
611
|
+
function cloneTombstones(value) {
|
|
612
|
+
return Object.fromEntries(COLLECTION_NAMES.map(name => [name, { ...value[name] }]));
|
|
613
|
+
}
|
|
614
|
+
async function readMetadata(path) {
|
|
615
|
+
let content;
|
|
616
|
+
try {
|
|
617
|
+
content = await readFile(path, 'utf8');
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
if (error.code === 'ENOENT')
|
|
621
|
+
return defaultMetadata();
|
|
622
|
+
throw error;
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
const input = asRecord(JSON.parse(content), 'Gist 同步状态');
|
|
626
|
+
if (input.schemaVersion !== 1)
|
|
627
|
+
throw new Error(`不支持的 Gist 同步状态版本:${String(input.schemaVersion)}`);
|
|
628
|
+
return {
|
|
629
|
+
schemaVersion: 1,
|
|
630
|
+
deviceId: text(input.deviceId, 'deviceId', 8, 100),
|
|
631
|
+
settings: normalizeSettings(input.settings),
|
|
632
|
+
tombstones: parseTombstones(input.tombstones),
|
|
633
|
+
...(typeof input.lastSyncedDigest === 'string' ? { lastSyncedDigest: input.lastSyncedDigest } : {}),
|
|
634
|
+
...(typeof input.lastSyncAt === 'number' ? { lastSyncAt: input.lastSyncAt } : {}),
|
|
635
|
+
...(input.lastResult === 'uploaded' || input.lastResult === 'downloaded' || input.lastResult === 'merged' || input.lastResult === 'unchanged' ? { lastResult: input.lastResult } : {}),
|
|
636
|
+
...(typeof input.lastError === 'string' ? { lastError: input.lastError } : {}),
|
|
637
|
+
...(typeof input.githubLogin === 'string' ? { githubLogin: text(input.githubLogin, 'githubLogin', 1, 100) } : {}),
|
|
638
|
+
...(typeof input.lastCloudVersion === 'string' ? { lastCloudVersion: revision(input.lastCloudVersion) } : {}),
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
catch (error) {
|
|
642
|
+
const backupPath = `${path}.corrupt.${Date.now()}`;
|
|
643
|
+
const preserved = await rename(path, backupPath).then(() => true, () => false);
|
|
644
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
645
|
+
return defaultMetadata(`本地 Gist 同步状态损坏,已安全重置${preserved ? '并保留原文件' : ''}:${reason}`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
function defaultMetadata(lastError) {
|
|
649
|
+
return {
|
|
650
|
+
schemaVersion: 1,
|
|
651
|
+
deviceId: randomBytes(16).toString('hex'),
|
|
652
|
+
settings: defaultSettings(),
|
|
653
|
+
tombstones: emptyTombstones(),
|
|
654
|
+
...(lastError === undefined ? {} : { lastError }),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
async function writeMetadata(path, value) {
|
|
658
|
+
await mkdir(dirname(path), { recursive: true });
|
|
659
|
+
const temporary = `${path}.${process.pid}.${randomBytes(5).toString('hex')}.tmp`;
|
|
660
|
+
const handle = await open(temporary, 'w', 0o600);
|
|
661
|
+
try {
|
|
662
|
+
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
663
|
+
await handle.sync();
|
|
664
|
+
}
|
|
665
|
+
finally {
|
|
666
|
+
await handle.close();
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
await rename(temporary, path);
|
|
670
|
+
}
|
|
671
|
+
catch (error) {
|
|
672
|
+
if (process.platform !== 'win32')
|
|
673
|
+
throw error;
|
|
674
|
+
await rm(path, { force: true });
|
|
675
|
+
await rename(temporary, path);
|
|
676
|
+
}
|
|
677
|
+
finally {
|
|
678
|
+
await rm(temporary, { force: true }).catch(() => { });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
function serializeSnapshot(value) {
|
|
682
|
+
const content = `${JSON.stringify(value, null, 2)}\n`;
|
|
683
|
+
return boundedContent(content);
|
|
684
|
+
}
|
|
685
|
+
function boundedContent(content) {
|
|
686
|
+
if (Buffer.byteLength(content, 'utf8') > MAX_GIST_BYTES)
|
|
687
|
+
throw new Error('Gist 配置超过 1 MB 限制');
|
|
688
|
+
return content;
|
|
689
|
+
}
|
|
690
|
+
function parseGist(value) {
|
|
691
|
+
const input = asRecord(value, 'GitHub Gist');
|
|
692
|
+
if (typeof input.public !== 'boolean')
|
|
693
|
+
throw new Error('GitHub 未返回 Gist 可见性');
|
|
694
|
+
if (input.public)
|
|
695
|
+
throw new Error('为避免暴露主机配置,SSH 同步只支持私有 Gist');
|
|
696
|
+
const files = asRecord(input.files, 'GitHub Gist files');
|
|
697
|
+
const parsedFiles = {};
|
|
698
|
+
for (const [name, raw] of Object.entries(files)) {
|
|
699
|
+
const file = asRecord(raw, `Gist file ${name}`);
|
|
700
|
+
parsedFiles[name] = {
|
|
701
|
+
filename: typeof file.filename === 'string' ? file.filename : name,
|
|
702
|
+
...(typeof file.content === 'string' ? { content: file.content } : {}),
|
|
703
|
+
...(typeof file.raw_url === 'string' ? { raw_url: file.raw_url } : {}),
|
|
704
|
+
...(file.truncated === true ? { truncated: true } : {}),
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
return {
|
|
708
|
+
id: normalizeGistId(input.id),
|
|
709
|
+
html_url: typeof input.html_url === 'string' ? input.html_url : `https://gist.github.com/${String(input.id)}`,
|
|
710
|
+
public: false,
|
|
711
|
+
...parseGistVersion(input),
|
|
712
|
+
files: parsedFiles,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
function parseProfile(value) {
|
|
716
|
+
const input = asRecord(value, 'SSH profile');
|
|
717
|
+
const draft = normalizeProfileDraft(input);
|
|
718
|
+
return {
|
|
719
|
+
id: text(input.id, 'profile.id', 1, 100), name: draft.name, ...(draft.group === undefined ? {} : { group: draft.group }), host: draft.host,
|
|
720
|
+
port: draft.port ?? 22, username: draft.username, authType: draft.authType, ...(draft.credentialId === undefined ? {} : { credentialId: draft.credentialId }),
|
|
721
|
+
...(draft.hostFingerprint === undefined ? {} : { hostFingerprint: draft.hostFingerprint }), proxy: draft.proxy ?? { type: 'none' },
|
|
722
|
+
keepAliveIntervalMs: draft.keepAliveIntervalMs ?? 15_000, connectTimeoutMs: draft.connectTimeoutMs ?? 15_000,
|
|
723
|
+
terminalType: draft.terminalType ?? 'xterm-256color', tags: draft.tags ?? [], createdAt: timestamp(input.createdAt, 'createdAt'), updatedAt: timestamp(input.updatedAt, 'updatedAt'),
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
function parseFtpProfile(value) {
|
|
727
|
+
const input = asRecord(value, 'FTP profile');
|
|
728
|
+
const draft = normalizeFtpProfileDraft(input);
|
|
729
|
+
return {
|
|
730
|
+
id: text(input.id, 'ftpProfile.id', 1, 100), name: draft.name, ...(draft.group === undefined ? {} : { group: draft.group }), protocol: draft.protocol,
|
|
731
|
+
host: draft.host, port: draft.port ?? (draft.protocol === 'ftps-implicit' ? 990 : 21), username: draft.username,
|
|
732
|
+
...(draft.credentialId === undefined ? {} : { credentialId: draft.credentialId }), proxy: draft.proxy ?? { type: 'none' }, initialPath: draft.initialPath ?? '/',
|
|
733
|
+
connectTimeoutMs: draft.connectTimeoutMs ?? 15_000, ...(draft.tlsServerName === undefined ? {} : { tlsServerName: draft.tlsServerName }),
|
|
734
|
+
createdAt: timestamp(input.createdAt, 'createdAt'), updatedAt: timestamp(input.updatedAt, 'updatedAt'),
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
function parseRemoteProject(value) {
|
|
738
|
+
const input = asRecord(value, 'remote project');
|
|
739
|
+
const draft = normalizeRemoteProjectDraft(input);
|
|
740
|
+
return { id: text(input.id, 'project.id', 1, 100), profileId: text(input.profileId, 'project.profileId', 1, 100), ...draft, createdAt: timestamp(input.createdAt, 'createdAt'), updatedAt: timestamp(input.updatedAt, 'updatedAt') };
|
|
741
|
+
}
|
|
742
|
+
function parseCredentialEntry(value) {
|
|
743
|
+
const input = asRecord(value, 'credential entry');
|
|
744
|
+
const draft = normalizeCredentialEntryDraft(input);
|
|
745
|
+
return { id: text(input.id, 'credential.id', 1, 100), ...draft, createdAt: timestamp(input.createdAt, 'createdAt'), updatedAt: timestamp(input.updatedAt, 'updatedAt') };
|
|
746
|
+
}
|
|
747
|
+
function parseProxyEntry(value) {
|
|
748
|
+
const input = asRecord(value, 'proxy entry');
|
|
749
|
+
const draft = normalizeProxyEntryDraft(input);
|
|
750
|
+
return { id: text(input.id, 'proxy.id', 1, 100), ...draft, createdAt: timestamp(input.createdAt, 'createdAt'), updatedAt: timestamp(input.updatedAt, 'updatedAt') };
|
|
751
|
+
}
|
|
752
|
+
function parseEncryptedSecretRecord(value) {
|
|
753
|
+
const input = asRecord(value, 'encrypted secret');
|
|
754
|
+
const scope = input.scope;
|
|
755
|
+
if (scope !== 'ssh-profile' && scope !== 'ftp-profile' && scope !== 'vault-entry' && scope !== 'proxy-entry')
|
|
756
|
+
throw new Error('加密凭据作用域无效');
|
|
757
|
+
const record = {
|
|
758
|
+
scope,
|
|
759
|
+
id: text(input.id, 'secret.id', 1, 100),
|
|
760
|
+
updatedAt: timestamp(input.updatedAt, 'secret.updatedAt'),
|
|
761
|
+
contentHash: hexDigest(input.contentHash, 'secret.contentHash'),
|
|
762
|
+
salt: base64(input.salt, 'secret.salt', 16),
|
|
763
|
+
iv: base64(input.iv, 'secret.iv', 12),
|
|
764
|
+
authTag: base64(input.authTag, 'secret.authTag', 16),
|
|
765
|
+
ciphertext: base64(input.ciphertext, 'secret.ciphertext', undefined, 700_000),
|
|
766
|
+
};
|
|
767
|
+
return record;
|
|
768
|
+
}
|
|
769
|
+
async function encryptSecretRecord(item, salt, key) {
|
|
770
|
+
const plaintext = Buffer.from(stableJson(parseSecretPayload(item.value)), 'utf8');
|
|
771
|
+
const iv = randomBytes(12);
|
|
772
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
773
|
+
cipher.setAAD(Buffer.from(`${item.scope}:${item.id}:${item.updatedAt}`, 'utf8'));
|
|
774
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
775
|
+
return {
|
|
776
|
+
scope: item.scope,
|
|
777
|
+
id: item.id,
|
|
778
|
+
updatedAt: item.updatedAt,
|
|
779
|
+
contentHash: createHash('sha256').update(plaintext).digest('hex'),
|
|
780
|
+
salt: salt.toString('base64'),
|
|
781
|
+
iv: iv.toString('base64'),
|
|
782
|
+
authTag: cipher.getAuthTag().toString('base64'),
|
|
783
|
+
ciphertext: ciphertext.toString('base64'),
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
async function decryptSecretRecords(records, passphrase) {
|
|
787
|
+
const output = new Map();
|
|
788
|
+
const keys = new Map();
|
|
789
|
+
for (const record of records) {
|
|
790
|
+
const salt = Buffer.from(record.salt, 'base64');
|
|
791
|
+
const iv = Buffer.from(record.iv, 'base64');
|
|
792
|
+
let keyPromise = keys.get(record.salt);
|
|
793
|
+
if (keyPromise === undefined) {
|
|
794
|
+
keyPromise = deriveEncryptionKey(passphrase, salt);
|
|
795
|
+
keys.set(record.salt, keyPromise);
|
|
796
|
+
}
|
|
797
|
+
const key = await keyPromise;
|
|
798
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
799
|
+
decipher.setAAD(Buffer.from(`${record.scope}:${record.id}:${record.updatedAt}`, 'utf8'));
|
|
800
|
+
decipher.setAuthTag(Buffer.from(record.authTag, 'base64'));
|
|
801
|
+
let plaintext;
|
|
802
|
+
try {
|
|
803
|
+
plaintext = Buffer.concat([decipher.update(Buffer.from(record.ciphertext, 'base64')), decipher.final()]);
|
|
804
|
+
}
|
|
805
|
+
catch {
|
|
806
|
+
throw new Error('无法解密 Gist 密钥数据,请检查同步加密密码');
|
|
807
|
+
}
|
|
808
|
+
if (createHash('sha256').update(plaintext).digest('hex') !== record.contentHash)
|
|
809
|
+
throw new Error('Gist 密钥数据完整性校验失败');
|
|
810
|
+
let value;
|
|
811
|
+
try {
|
|
812
|
+
value = JSON.parse(plaintext.toString('utf8'));
|
|
813
|
+
}
|
|
814
|
+
catch {
|
|
815
|
+
throw new Error('Gist 密钥数据不是有效 JSON');
|
|
816
|
+
}
|
|
817
|
+
output.set(secretKey(record.scope, record.id), parseSecretPayload(value));
|
|
818
|
+
}
|
|
819
|
+
return output;
|
|
820
|
+
}
|
|
821
|
+
export async function decryptPortableSecrets(snapshot, passphrase) {
|
|
822
|
+
return Object.fromEntries(await decryptSecretRecords(snapshot.secrets, passphrase));
|
|
823
|
+
}
|
|
824
|
+
function mergeSecretRecords(left, right, snapshot) {
|
|
825
|
+
const records = new Map();
|
|
826
|
+
for (const candidate of [...left, ...right]) {
|
|
827
|
+
if (!secretOwnerExists(candidate, snapshot.collections))
|
|
828
|
+
continue;
|
|
829
|
+
const key = secretKey(candidate.scope, candidate.id);
|
|
830
|
+
const previous = records.get(key);
|
|
831
|
+
if (previous === undefined || candidate.updatedAt > previous.updatedAt || candidate.updatedAt === previous.updatedAt && candidate.contentHash > previous.contentHash) {
|
|
832
|
+
records.set(key, structuredClone(candidate));
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return [...records.values()].sort((a, b) => secretKey(a.scope, a.id).localeCompare(secretKey(b.scope, b.id)));
|
|
836
|
+
}
|
|
837
|
+
function secretOwnerExists(record, collections) {
|
|
838
|
+
if (record.scope === 'ssh-profile')
|
|
839
|
+
return collections.profiles.some(item => item.id === record.id && item.credentialId === undefined);
|
|
840
|
+
if (record.scope === 'ftp-profile')
|
|
841
|
+
return collections.ftpProfiles.some(item => item.id === record.id && item.credentialId === undefined);
|
|
842
|
+
if (record.scope === 'vault-entry')
|
|
843
|
+
return collections.credentialEntries.some(item => item.id === record.id);
|
|
844
|
+
return collections.proxyEntries.some(item => item.id === record.id);
|
|
845
|
+
}
|
|
846
|
+
function secretKey(scope, id) { return `${scope}:${id}`; }
|
|
847
|
+
function deriveEncryptionKey(passphrase, salt) {
|
|
848
|
+
const normalized = normalizeEncryptionPassphrase(passphrase);
|
|
849
|
+
return new Promise((resolve, reject) => {
|
|
850
|
+
scrypt(normalized, salt, 32, (error, key) => { if (error !== null)
|
|
851
|
+
reject(error);
|
|
852
|
+
else
|
|
853
|
+
resolve(key); });
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
function parseSecretPayload(value) {
|
|
857
|
+
const input = asRecord(value, 'credential payload');
|
|
858
|
+
const output = {};
|
|
859
|
+
for (const field of ['password', 'privateKey', 'passphrase', 'proxyPassword']) {
|
|
860
|
+
const candidate = input[field];
|
|
861
|
+
if (candidate === undefined)
|
|
862
|
+
continue;
|
|
863
|
+
if (typeof candidate !== 'string' || candidate.length === 0 || candidate.length > (field === 'privateKey' ? 512_000 : 16_384))
|
|
864
|
+
throw new Error(`凭据字段 ${field} 无效`);
|
|
865
|
+
output[field] = candidate;
|
|
866
|
+
}
|
|
867
|
+
return output;
|
|
868
|
+
}
|
|
869
|
+
function parseTombstones(value) {
|
|
870
|
+
const input = value === undefined ? {} : asRecord(value, 'tombstones');
|
|
871
|
+
const result = emptyTombstones();
|
|
872
|
+
for (const name of COLLECTION_NAMES) {
|
|
873
|
+
const entries = input[name] === undefined ? {} : asRecord(input[name], `tombstones.${name}`);
|
|
874
|
+
for (const [id, raw] of Object.entries(entries))
|
|
875
|
+
result[name][text(id, 'tombstone id', 1, 100)] = timestamp(raw, 'deletedAt');
|
|
876
|
+
}
|
|
877
|
+
return result;
|
|
878
|
+
}
|
|
879
|
+
function assertUniqueIds(snapshot) {
|
|
880
|
+
for (const name of COLLECTION_NAMES) {
|
|
881
|
+
const ids = snapshot.collections[name].map(item => item.id);
|
|
882
|
+
if (new Set(ids).size !== ids.length)
|
|
883
|
+
throw new Error(`Gist 配置包含重复的 ${name} ID`);
|
|
884
|
+
}
|
|
885
|
+
const secretIds = snapshot.secrets.map(item => secretKey(item.scope, item.id));
|
|
886
|
+
if (new Set(secretIds).size !== secretIds.length)
|
|
887
|
+
throw new Error('Gist 配置包含重复的加密凭据');
|
|
888
|
+
if (snapshot.secrets.some(item => !secretOwnerExists(item, snapshot.collections)))
|
|
889
|
+
throw new Error('Gist 配置包含无归属的加密凭据');
|
|
890
|
+
}
|
|
891
|
+
function assertPortableReferences(collections) {
|
|
892
|
+
const profiles = new Set(collections.profiles.map(item => item.id));
|
|
893
|
+
const credentials = new Set(collections.credentialEntries.map(item => item.id));
|
|
894
|
+
const proxies = new Set(collections.proxyEntries.map(item => item.id));
|
|
895
|
+
for (const profile of collections.profiles) {
|
|
896
|
+
if (profile.credentialId !== undefined && !credentials.has(profile.credentialId))
|
|
897
|
+
throw new Error(`主机 ${profile.name} 引用了缺失的凭据条目`);
|
|
898
|
+
if (profile.proxy.type === 'saved' && !proxies.has(profile.proxy.proxyId))
|
|
899
|
+
throw new Error(`主机 ${profile.name} 引用了缺失的代理`);
|
|
900
|
+
if (profile.proxy.type === 'jump' && profile.proxy.profileIds.some(id => !profiles.has(id) || id === profile.id))
|
|
901
|
+
throw new Error(`主机 ${profile.name} 的跳板链无效`);
|
|
902
|
+
}
|
|
903
|
+
for (const profile of collections.ftpProfiles) {
|
|
904
|
+
if (profile.credentialId !== undefined && !credentials.has(profile.credentialId))
|
|
905
|
+
throw new Error(`FTP ${profile.name} 引用了缺失的凭据条目`);
|
|
906
|
+
if (profile.proxy.type === 'saved' && !proxies.has(profile.proxy.proxyId))
|
|
907
|
+
throw new Error(`FTP ${profile.name} 引用了缺失的代理`);
|
|
908
|
+
}
|
|
909
|
+
for (const project of collections.remoteProjects)
|
|
910
|
+
if (!profiles.has(project.profileId))
|
|
911
|
+
throw new Error(`远端项目 ${project.name} 引用了缺失的主机`);
|
|
912
|
+
}
|
|
913
|
+
function repairReferences(snapshot, deletedAt) {
|
|
914
|
+
const profiles = new Set(snapshot.collections.profiles.map(item => item.id));
|
|
915
|
+
const credentials = new Set(snapshot.collections.credentialEntries.map(item => item.id));
|
|
916
|
+
const proxies = new Set(snapshot.collections.proxyEntries.map(item => item.id));
|
|
917
|
+
const invalidProfiles = new Set(snapshot.collections.profiles.filter(profile => profile.credentialId !== undefined && !credentials.has(profile.credentialId)
|
|
918
|
+
|| profile.proxy.type === 'saved' && !proxies.has(profile.proxy.proxyId)
|
|
919
|
+
|| profile.proxy.type === 'jump' && profile.proxy.profileIds.some(id => !profiles.has(id) || id === profile.id)).map(item => item.id));
|
|
920
|
+
for (const id of invalidProfiles)
|
|
921
|
+
snapshot.tombstones.profiles[id] = Math.max(snapshot.tombstones.profiles[id] ?? 0, deletedAt);
|
|
922
|
+
snapshot.collections.profiles = snapshot.collections.profiles.filter(item => !invalidProfiles.has(item.id));
|
|
923
|
+
const survivingProfiles = new Set(snapshot.collections.profiles.map(item => item.id));
|
|
924
|
+
const invalidFtp = new Set(snapshot.collections.ftpProfiles.filter(profile => profile.credentialId !== undefined && !credentials.has(profile.credentialId)
|
|
925
|
+
|| profile.proxy.type === 'saved' && !proxies.has(profile.proxy.proxyId)).map(item => item.id));
|
|
926
|
+
for (const id of invalidFtp)
|
|
927
|
+
snapshot.tombstones.ftpProfiles[id] = Math.max(snapshot.tombstones.ftpProfiles[id] ?? 0, deletedAt);
|
|
928
|
+
snapshot.collections.ftpProfiles = snapshot.collections.ftpProfiles.filter(item => !invalidFtp.has(item.id));
|
|
929
|
+
const invalidProjects = new Set(snapshot.collections.remoteProjects.filter(project => !survivingProfiles.has(project.profileId)).map(item => item.id));
|
|
930
|
+
for (const id of invalidProjects)
|
|
931
|
+
snapshot.tombstones.remoteProjects[id] = Math.max(snapshot.tombstones.remoteProjects[id] ?? 0, deletedAt);
|
|
932
|
+
snapshot.collections.remoteProjects = snapshot.collections.remoteProjects.filter(item => !invalidProjects.has(item.id));
|
|
933
|
+
}
|
|
934
|
+
function selectNewest(left, right) {
|
|
935
|
+
if (left === undefined)
|
|
936
|
+
return right;
|
|
937
|
+
if (right === undefined)
|
|
938
|
+
return left;
|
|
939
|
+
if (left.updatedAt !== right.updatedAt)
|
|
940
|
+
return left.updatedAt > right.updatedAt ? left : right;
|
|
941
|
+
return stableJson(left) >= stableJson(right) ? left : right;
|
|
942
|
+
}
|
|
943
|
+
function sortItems(items) { return structuredClone(items).sort((a, b) => a.id.localeCompare(b.id)); }
|
|
944
|
+
function uniqueIds(...groups) { return [...new Set(groups.flatMap(group => group.map(item => item.id)))]; }
|
|
945
|
+
function snapshotCollectionDigest(items) { return createHash('sha256').update(stableJson(sortItems(items))).digest('hex'); }
|
|
946
|
+
function snapshotHasPortableData(snapshot) {
|
|
947
|
+
return COLLECTION_NAMES.some(name => snapshot.collections[name].length > 0 || Object.keys(snapshot.tombstones[name]).length > 0)
|
|
948
|
+
|| snapshot.secrets.length > 0;
|
|
949
|
+
}
|
|
950
|
+
function stableJson(value) { return JSON.stringify(stableValue(value)); }
|
|
951
|
+
function stableValue(value) {
|
|
952
|
+
if (Array.isArray(value))
|
|
953
|
+
return value.map(stableValue);
|
|
954
|
+
if (typeof value !== 'object' || value === null)
|
|
955
|
+
return value;
|
|
956
|
+
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, stableValue(item)]));
|
|
957
|
+
}
|
|
958
|
+
function normalizeToken(value) {
|
|
959
|
+
const token = value.trim();
|
|
960
|
+
if (token.length < 20 || token.length > 512 || /\s/.test(token))
|
|
961
|
+
throw new Error('GitHub Token 格式无效');
|
|
962
|
+
return token;
|
|
963
|
+
}
|
|
964
|
+
function normalizeEncryptionPassphrase(value) {
|
|
965
|
+
if (value.length < 6 || value.length > 512)
|
|
966
|
+
throw new Error('同步加密密码必须包含 6 到 512 个字符');
|
|
967
|
+
return value;
|
|
968
|
+
}
|
|
969
|
+
function parseGistCredentialPayload(value) {
|
|
970
|
+
const payload = asRecord(value, 'Gist 同步凭据');
|
|
971
|
+
const token = payload.token;
|
|
972
|
+
const encryptionPassphrase = payload.encryptionPassphrase;
|
|
973
|
+
if (token !== undefined && (typeof token !== 'string' || token.length < 20 || token.length > 512))
|
|
974
|
+
throw new Error('Gist Token 格式无效');
|
|
975
|
+
if (encryptionPassphrase !== undefined && (typeof encryptionPassphrase !== 'string' || encryptionPassphrase.length < 6 || encryptionPassphrase.length > 512))
|
|
976
|
+
throw new Error('同步加密密码格式无效');
|
|
977
|
+
return {
|
|
978
|
+
...(typeof token === 'string' ? { token } : {}),
|
|
979
|
+
...(typeof encryptionPassphrase === 'string' ? { encryptionPassphrase } : {}),
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
function normalizeGistId(value) {
|
|
983
|
+
if (typeof value !== 'string' || !/^[a-fA-F0-9]{5,64}$/.test(value.trim()))
|
|
984
|
+
throw new Error('Gist ID 格式无效');
|
|
985
|
+
return value.trim().toLowerCase();
|
|
986
|
+
}
|
|
987
|
+
function normalizeOAuthClientId(value) {
|
|
988
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9._-]{10,128}$/.test(value.trim()))
|
|
989
|
+
throw new Error('GitHub OAuth Client ID 格式无效');
|
|
990
|
+
return value.trim();
|
|
991
|
+
}
|
|
992
|
+
function parseGistVersion(input) {
|
|
993
|
+
if (!Array.isArray(input.history) || input.history.length === 0)
|
|
994
|
+
return {};
|
|
995
|
+
const latest = input.history[0];
|
|
996
|
+
if (typeof latest !== 'object' || latest === null || Array.isArray(latest))
|
|
997
|
+
return {};
|
|
998
|
+
const value = latest.version;
|
|
999
|
+
return typeof value === 'string' ? { version: revision(value) } : {};
|
|
1000
|
+
}
|
|
1001
|
+
function revision(value) {
|
|
1002
|
+
if (!/^[a-fA-F0-9]{7,64}$/.test(value))
|
|
1003
|
+
throw new Error('Gist 云端版本格式无效');
|
|
1004
|
+
return value.toLowerCase();
|
|
1005
|
+
}
|
|
1006
|
+
function asRecord(value, label) {
|
|
1007
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
1008
|
+
throw new Error(`${label}必须是对象`);
|
|
1009
|
+
return value;
|
|
1010
|
+
}
|
|
1011
|
+
function array(value, label) {
|
|
1012
|
+
if (!Array.isArray(value) || value.length > 10_000)
|
|
1013
|
+
throw new Error(`${label} 必须是有效数组`);
|
|
1014
|
+
return value;
|
|
1015
|
+
}
|
|
1016
|
+
function text(value, label, min, max) {
|
|
1017
|
+
if (typeof value !== 'string' || value.length < min || value.length > max)
|
|
1018
|
+
throw new Error(`${label} 格式无效`);
|
|
1019
|
+
return value;
|
|
1020
|
+
}
|
|
1021
|
+
function timestamp(value, label) {
|
|
1022
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
|
|
1023
|
+
throw new Error(`${label} 格式无效`);
|
|
1024
|
+
return value;
|
|
1025
|
+
}
|
|
1026
|
+
function hexDigest(value, label) {
|
|
1027
|
+
if (typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value))
|
|
1028
|
+
throw new Error(`${label} 格式无效`);
|
|
1029
|
+
return value;
|
|
1030
|
+
}
|
|
1031
|
+
function base64(value, label, exactBytes, maxBytes = exactBytes) {
|
|
1032
|
+
if (typeof value !== 'string' || value.length === 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value))
|
|
1033
|
+
throw new Error(`${label} 格式无效`);
|
|
1034
|
+
const decoded = Buffer.from(value, 'base64');
|
|
1035
|
+
if (exactBytes !== undefined && decoded.length !== exactBytes)
|
|
1036
|
+
throw new Error(`${label} 长度无效`);
|
|
1037
|
+
if (maxBytes !== undefined && decoded.length > maxBytes)
|
|
1038
|
+
throw new Error(`${label} 超出大小限制`);
|
|
1039
|
+
if (decoded.toString('base64') !== value)
|
|
1040
|
+
throw new Error(`${label} 编码无效`);
|
|
1041
|
+
return value;
|
|
1042
|
+
}
|
|
1043
|
+
//# sourceMappingURL=gist-sync.js.map
|