dsh-zspace 0.1.0 → 0.1.2
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/CHANGELOG.md +31 -0
- package/README.md +20 -4
- package/cordis.patch.yml +4 -2
- package/lib/README.md +74 -0
- package/lib/client/browse.js +58 -0
- package/lib/client/download.js +115 -0
- package/lib/client/errors.js +56 -0
- package/lib/client/mutate.js +72 -0
- package/lib/client/session.js +72 -0
- package/lib/client/spaces.js +122 -0
- package/lib/client/transport.js +285 -0
- package/lib/client/upload.js +117 -0
- package/lib/client.js +71 -737
- package/lib/config.js +109 -0
- package/lib/index.js +7 -126
- package/lib/prompt.js +26 -0
- package/lib/tools/copy.js +37 -0
- package/lib/tools/download.js +44 -0
- package/lib/tools/find.js +76 -0
- package/lib/tools/ls.js +72 -0
- package/lib/tools/mkdir.js +34 -0
- package/lib/tools/move.js +37 -0
- package/lib/tools/paths.js +84 -0
- package/lib/tools/read.js +55 -0
- package/lib/tools/remove.js +40 -0
- package/lib/tools/rename.js +39 -0
- package/lib/tools/shared.js +52 -0
- package/lib/tools/stat.js +53 -0
- package/lib/tools/status.js +116 -0
- package/lib/tools/upload.js +58 -0
- package/lib/tools/walk.js +117 -0
- package/lib/tools/write.js +73 -0
- package/lib/tools.js +51 -788
- package/package.json +3 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy liveness and NAS space discovery: storage pools, personal-space and public-space roots.
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-zspace/client
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import https from "node:https";
|
|
8
|
+
import http from "node:http";
|
|
9
|
+
import { ZSpaceError } from "./errors.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Probe the desktop client proxy.
|
|
13
|
+
*
|
|
14
|
+
* @returns {Promise<boolean>} true when the proxy answers below 500.
|
|
15
|
+
*/
|
|
16
|
+
export async function check(client) {
|
|
17
|
+
const url = `${client.baseUrl}/home/`;
|
|
18
|
+
return await new Promise((resolve) => {
|
|
19
|
+
const parsed = new URL(url);
|
|
20
|
+
const transport = parsed.protocol === "https:" ? https : http;
|
|
21
|
+
const request = transport.get(url, { timeout: 3000 }, (response) => {
|
|
22
|
+
response.resume();
|
|
23
|
+
resolve((response.statusCode ?? 500) < 500);
|
|
24
|
+
});
|
|
25
|
+
request.setTimeout(3000, () => request.destroy());
|
|
26
|
+
request.on("error", () => resolve(false));
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Storage pools (`/zspool/info`), cached per client.
|
|
32
|
+
*
|
|
33
|
+
* @returns {Promise<Array<{name: string, status: string, totalSize: number, freeSize: number}>>} pools.
|
|
34
|
+
*/
|
|
35
|
+
export async function pools(client) {
|
|
36
|
+
if (client._pools !== null) return client._pools;
|
|
37
|
+
const data = await client.post("/zspool/info", {});
|
|
38
|
+
const list = Array.isArray(data?.pool_list) ? data.pool_list : [];
|
|
39
|
+
client._pools = list.map((pool) => ({
|
|
40
|
+
name: String(pool.name ?? ""),
|
|
41
|
+
status: String(pool.status ?? ""),
|
|
42
|
+
totalSize: Number(pool.total_size ?? 0),
|
|
43
|
+
freeSize: Number(pool.free_size ?? 0),
|
|
44
|
+
}));
|
|
45
|
+
return client._pools;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a remote directory can be listed.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} remotePath - absolute remote path.
|
|
52
|
+
* @returns {Promise<boolean>} true when listing succeeds.
|
|
53
|
+
*/
|
|
54
|
+
export async function isDirectory(client, remotePath) {
|
|
55
|
+
try {
|
|
56
|
+
await client.post("/v2/file/list", { path: remotePath, show_hidden: "0", start: "0", limit: "1" });
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Locate the personal-space root, e.g. `/<pool>/my/data`.
|
|
65
|
+
*
|
|
66
|
+
* Uses the configured path when set, otherwise tries `/<pool>/my/data` for
|
|
67
|
+
* every storage pool and falls back to the historical `/sata11/my/data`.
|
|
68
|
+
*
|
|
69
|
+
* @returns {Promise<string>} personal-space root.
|
|
70
|
+
* @throws {ZSpaceError} when no candidate is listable.
|
|
71
|
+
*/
|
|
72
|
+
export async function homePath(client) {
|
|
73
|
+
if (client._homePath !== null) return client._homePath;
|
|
74
|
+
const candidates = [];
|
|
75
|
+
if (client.configuredHomePath) {
|
|
76
|
+
candidates.push(client.configuredHomePath);
|
|
77
|
+
} else {
|
|
78
|
+
for (const pool of await client.pools().catch(() => [])) candidates.push(`/${pool.name}/my/data`);
|
|
79
|
+
candidates.push("/sata11/my/data", "/sata1/my/data");
|
|
80
|
+
}
|
|
81
|
+
for (const candidate of [...new Set(candidates)]) {
|
|
82
|
+
if (await client.isDirectory(candidate)) {
|
|
83
|
+
client._homePath = candidate;
|
|
84
|
+
return candidate;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
throw new ZSpaceError("NOHOME", `无法定位个人空间根目录,已尝试:${candidates.join("、")}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Locate the public-space root, e.g. `/<pool>/public`.
|
|
92
|
+
*
|
|
93
|
+
* @returns {Promise<string>} public-space root.
|
|
94
|
+
* @throws {ZSpaceError} when no candidate is listable (account may lack access).
|
|
95
|
+
*/
|
|
96
|
+
export async function publicPath(client) {
|
|
97
|
+
if (client._publicPath !== null) return client._publicPath;
|
|
98
|
+
const candidates = [];
|
|
99
|
+
if (client.configuredPublicPath) {
|
|
100
|
+
candidates.push(client.configuredPublicPath);
|
|
101
|
+
} else {
|
|
102
|
+
for (const pool of await client.pools().catch(() => [])) candidates.push(`/${pool.name}/public`);
|
|
103
|
+
candidates.push("/public", "/sata1/public");
|
|
104
|
+
}
|
|
105
|
+
for (const candidate of [...new Set(candidates)]) {
|
|
106
|
+
if (await client.isDirectory(candidate)) {
|
|
107
|
+
client._publicPath = candidate;
|
|
108
|
+
return candidate;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
throw new ZSpaceError("NOPUBLIC", `无法定位公共空间(账号可能没有公共空间权限),已尝试:${candidates.join("、")}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── file operations ──────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* List one directory, paging until the NAS has no more rows.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} remotePath - absolute remote directory.
|
|
120
|
+
* @param {{showHidden?: boolean, maxEntries?: number}} [options] - listing options.
|
|
121
|
+
* @returns {Promise<{entries: Array<ReturnType<typeof toEntry>>, truncated: boolean}>} listing.
|
|
122
|
+
*/
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP transport to the desktop-client proxy: URL shaping, form encoding, retries, response validation.
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-zspace/client
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fsp from "node:fs/promises";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import https from "node:https";
|
|
10
|
+
import http from "node:http";
|
|
11
|
+
import { ZSpaceError } from "./errors.js";
|
|
12
|
+
|
|
13
|
+
const FORM_TYPE = "application/x-www-form-urlencoded";
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_PAGE_SIZE = 50;
|
|
16
|
+
|
|
17
|
+
/** Network errno values worth a retry; a down client is not among them. */
|
|
18
|
+
const RETRYABLE_ERRNOS = new Set(["ECONNRESET", "ETIMEDOUT", "EPIPE", "EAI_AGAIN", "ENETUNREACH"]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Random token appended to every request URL.
|
|
22
|
+
*
|
|
23
|
+
* @returns {string} `?&rnd=` value.
|
|
24
|
+
*/
|
|
25
|
+
function randomToken() {
|
|
26
|
+
return `${Date.now()}${Math.floor(Math.random() * 9000 + 1000)}_${Math.floor(Math.random() * 9000 + 1000)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Percent-encode one value for a form field, query value or upload header.
|
|
31
|
+
*
|
|
32
|
+
* @param {unknown} value - value to encode.
|
|
33
|
+
* @returns {string} encoded value.
|
|
34
|
+
*/
|
|
35
|
+
export function percentEncode(value) {
|
|
36
|
+
return encodeURIComponent(String(value));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether a transport error is worth retrying.
|
|
41
|
+
*
|
|
42
|
+
* @param {unknown} error - thrown value.
|
|
43
|
+
* @returns {boolean} true when a retry may succeed.
|
|
44
|
+
*/
|
|
45
|
+
function isRetryableTransportError(error) {
|
|
46
|
+
const errno = /** @type {{code?: string, name?: string}} */ (error)?.code;
|
|
47
|
+
if (errno === "ECONNREFUSED" || errno === "ENOTFOUND" || errno === "EHOSTUNREACH") return false;
|
|
48
|
+
if (errno !== undefined && RETRYABLE_ERRNOS.has(errno)) return true;
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read a whole stream with a byte cap.
|
|
54
|
+
*
|
|
55
|
+
* @param {import("node:stream").Readable} stream - source stream.
|
|
56
|
+
* @param {number} [maxBytes] - stop after this many bytes.
|
|
57
|
+
* @returns {Promise<{buffer: Buffer, truncated: boolean}>} collected bytes.
|
|
58
|
+
*/
|
|
59
|
+
export async function readStream(stream, maxBytes = Number.POSITIVE_INFINITY) {
|
|
60
|
+
const chunks = [];
|
|
61
|
+
let total = 0;
|
|
62
|
+
let truncated = false;
|
|
63
|
+
for await (const chunk of stream) {
|
|
64
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
65
|
+
if (total + buffer.length > maxBytes) {
|
|
66
|
+
chunks.push(buffer.subarray(0, Math.max(0, maxBytes - total)));
|
|
67
|
+
total = maxBytes;
|
|
68
|
+
truncated = true;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
chunks.push(buffer);
|
|
72
|
+
total += buffer.length;
|
|
73
|
+
}
|
|
74
|
+
return { buffer: Buffer.concat(chunks), truncated };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build an endpoint URL with the mandatory random query suffix.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} endpoint - API path, e.g. `/v2/file/list`.
|
|
81
|
+
* @param {Record<string, unknown>} [query] - extra query values.
|
|
82
|
+
* @returns {string} absolute URL.
|
|
83
|
+
*/
|
|
84
|
+
export function endpointUrl(client, endpoint, query) {
|
|
85
|
+
let url = `${client.baseUrl}${endpoint}?&rnd=${randomToken()}&webagent=v2`;
|
|
86
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
87
|
+
if (value === undefined || value === null) continue;
|
|
88
|
+
url += `&${key}=${percentEncode(value)}`;
|
|
89
|
+
}
|
|
90
|
+
return url;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Encode form fields, expanding arrays into repeated `key[]` entries.
|
|
95
|
+
*
|
|
96
|
+
* @param {Record<string, unknown>} fields - form fields.
|
|
97
|
+
* @returns {string} urlencoded body.
|
|
98
|
+
*/
|
|
99
|
+
export function formBody(client, fields) {
|
|
100
|
+
const parts = [];
|
|
101
|
+
for (const [key, value] of Object.entries({ ...client.commonParams(), ...fields })) {
|
|
102
|
+
if (value === undefined || value === null) continue;
|
|
103
|
+
if (Array.isArray(value)) {
|
|
104
|
+
for (const item of value) parts.push(`${percentEncode(key)}%5B%5D=${percentEncode(item)}`);
|
|
105
|
+
} else {
|
|
106
|
+
parts.push(`${percentEncode(key)}=${percentEncode(value)}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return parts.join("&");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Retry a transport call with exponential backoff.
|
|
114
|
+
*
|
|
115
|
+
* @template T
|
|
116
|
+
* @param {() => Promise<T>} attempt - one attempt.
|
|
117
|
+
* @param {(error: unknown) => boolean} shouldRetry - retry predicate.
|
|
118
|
+
* @returns {Promise<T>} the first successful result.
|
|
119
|
+
*/
|
|
120
|
+
export async function retrying(client, attempt, shouldRetry) {
|
|
121
|
+
let lastError;
|
|
122
|
+
for (let index = 0; index <= client.maxRetries; index += 1) {
|
|
123
|
+
try {
|
|
124
|
+
return await attempt();
|
|
125
|
+
} catch (error) {
|
|
126
|
+
lastError = error;
|
|
127
|
+
if (index >= client.maxRetries || !shouldRetry(error)) throw error;
|
|
128
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(client.retryDelayMs * 2 ** index, 2000)));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
throw lastError;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Send one buffered request.
|
|
136
|
+
*
|
|
137
|
+
* @param {object} options - request options.
|
|
138
|
+
* @param {"GET"|"POST"} [options.method] - HTTP method.
|
|
139
|
+
* @param {string} options.endpoint - API path.
|
|
140
|
+
* @param {Record<string, unknown>} [options.query] - extra query values.
|
|
141
|
+
* @param {Record<string, unknown>} [options.form] - form fields (merged with common params).
|
|
142
|
+
* @param {Record<string, unknown>} [options.fields] - raw fields appended without common params.
|
|
143
|
+
* @param {Buffer} [options.body] - raw request body.
|
|
144
|
+
* @param {string} [options.filePath] - stream this file as the request body.
|
|
145
|
+
* @param {Record<string, string>} [options.headers] - extra headers (override auth).
|
|
146
|
+
* @param {string} [options.url] - absolute URL override (sliced upload).
|
|
147
|
+
* @param {number} [options.timeoutMs] - per-attempt timeout.
|
|
148
|
+
* @returns {Promise<{status: number, headers: import("node:http").IncomingHttpHeaders, text: string}>} response.
|
|
149
|
+
*/
|
|
150
|
+
export async function send(client, options) {
|
|
151
|
+
const attempt = async () => {
|
|
152
|
+
const url = options.url ?? client.endpointUrl(options.endpoint, options.query);
|
|
153
|
+
const headers = { ...client.authHeaders(), ...(options.headers ?? {}) };
|
|
154
|
+
let payload;
|
|
155
|
+
if (options.form !== undefined || options.fields !== undefined) {
|
|
156
|
+
payload = Buffer.from(
|
|
157
|
+
options.form !== undefined
|
|
158
|
+
? client.formBody(options.form)
|
|
159
|
+
: Object.entries(options.fields ?? {})
|
|
160
|
+
.map(([key, value]) => `${percentEncode(key)}=${percentEncode(value)}`)
|
|
161
|
+
.join("&"),
|
|
162
|
+
"utf8",
|
|
163
|
+
);
|
|
164
|
+
headers["Content-Type"] = headers["Content-Type"] ?? FORM_TYPE;
|
|
165
|
+
headers["Content-Length"] = String(payload.length);
|
|
166
|
+
} else if (options.filePath !== undefined) {
|
|
167
|
+
const stat = await fsp.stat(options.filePath);
|
|
168
|
+
headers["Content-Length"] = String(stat.size);
|
|
169
|
+
} else if (options.body !== undefined) {
|
|
170
|
+
payload = options.body;
|
|
171
|
+
headers["Content-Length"] = headers["Content-Length"] ?? String(payload.length);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return await new Promise((resolve, reject) => {
|
|
175
|
+
const parsed = new URL(url);
|
|
176
|
+
const transport = parsed.protocol === "https:" ? https : http;
|
|
177
|
+
const request = transport.request(
|
|
178
|
+
url,
|
|
179
|
+
{ method: options.method ?? "POST", headers },
|
|
180
|
+
(response) => {
|
|
181
|
+
readStream(response, 64 * 1024 * 1024).then(
|
|
182
|
+
({ buffer }) => {
|
|
183
|
+
const status = response.statusCode ?? 0;
|
|
184
|
+
// 5xx/429 mean the proxy hiccuped, not that the call was
|
|
185
|
+
// wrong — surface them as retryable failures here, where the
|
|
186
|
+
// retry loop can still see them.
|
|
187
|
+
if (status >= 500 || status === 429) {
|
|
188
|
+
reject(
|
|
189
|
+
new ZSpaceError(`HTTP${status}`, `HTTP ${status} on ${options.endpoint ?? url}: ${buffer.toString("utf8").slice(0, 200)}`, {
|
|
190
|
+
endpoint: options.endpoint ?? url,
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
resolve({ status, headers: response.headers, text: buffer.toString("utf8") });
|
|
196
|
+
},
|
|
197
|
+
reject,
|
|
198
|
+
);
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
request.setTimeout(options.timeoutMs ?? client.timeoutMs, () => {
|
|
202
|
+
request.destroy(new ZSpaceError("ETIMEDOUT", `请求超时(${options.endpoint ?? options.url})`));
|
|
203
|
+
});
|
|
204
|
+
request.on("error", (error) => {
|
|
205
|
+
if (error instanceof ZSpaceError) {
|
|
206
|
+
reject(error);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
reject(
|
|
210
|
+
new ZSpaceError("EPROXY", `无法访问极空间桌面客户端代理 ${client.baseUrl}:${error.message}`, {
|
|
211
|
+
endpoint: options.endpoint ?? url,
|
|
212
|
+
cause: error,
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
if (options.filePath !== undefined) {
|
|
217
|
+
const source = fs.createReadStream(options.filePath);
|
|
218
|
+
source.on("error", reject);
|
|
219
|
+
source.pipe(request);
|
|
220
|
+
} else {
|
|
221
|
+
request.end(payload);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
return await client.retrying(attempt, error => {
|
|
227
|
+
if (error instanceof ZSpaceError) {
|
|
228
|
+
if (error.code === "ETIMEDOUT") return true;
|
|
229
|
+
if (error.code === "EPROXY") return isRetryableTransportError(error.cause);
|
|
230
|
+
// The desktop proxy hiccups with 5xx/429 now and then; the NAS's
|
|
231
|
+
// business codes (N00…) are deterministic and never retried here.
|
|
232
|
+
return /^HTTP(5\d\d|429)$/.test(error.code);
|
|
233
|
+
}
|
|
234
|
+
return isRetryableTransportError(error);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Validate a buffered JSON response and unwrap its `data`.
|
|
240
|
+
*
|
|
241
|
+
* @param {{status: number, text: string}} response - raw response.
|
|
242
|
+
* @param {string} endpoint - endpoint for error context.
|
|
243
|
+
* @returns {any} the response `data` (or the whole body when it has none).
|
|
244
|
+
* @throws {ZSpaceError} on HTTP failures, HTTP 413 and non-200 business codes.
|
|
245
|
+
*/
|
|
246
|
+
export function parse(client, response, endpoint) {
|
|
247
|
+
const { status, text } = response;
|
|
248
|
+
if (status === 413) {
|
|
249
|
+
throw new ZSpaceError("HTTP413", `本地代理拒绝该请求体(HTTP 413)`, { endpoint });
|
|
250
|
+
}
|
|
251
|
+
if (status >= 400) {
|
|
252
|
+
throw new ZSpaceError(`HTTP${status}`, `HTTP ${status} on ${endpoint}: ${text.slice(0, 200)}`, { endpoint });
|
|
253
|
+
}
|
|
254
|
+
let body;
|
|
255
|
+
try {
|
|
256
|
+
body = JSON.parse(text);
|
|
257
|
+
} catch {
|
|
258
|
+
throw new ZSpaceError("BAD_JSON", `${endpoint} 返回了非 JSON 内容:${text.slice(0, 200)}`, { endpoint });
|
|
259
|
+
}
|
|
260
|
+
const code = body.code ?? body.errcode;
|
|
261
|
+
if (code !== undefined && String(code) !== "200") {
|
|
262
|
+
throw new ZSpaceError(String(code), `${body.msg ?? body.message ?? "error"}(${endpoint})`, { endpoint });
|
|
263
|
+
}
|
|
264
|
+
return body.data !== undefined ? body.data : body;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* POST form fields and return the unwrapped `data`.
|
|
269
|
+
*
|
|
270
|
+
* @param {string} endpoint - API path.
|
|
271
|
+
* @param {Record<string, unknown>} [fields] - form fields.
|
|
272
|
+
* @returns {Promise<any>} response data.
|
|
273
|
+
*/
|
|
274
|
+
export async function post(client, endpoint, fields) {
|
|
275
|
+
const response = await client.send({ method: "POST", endpoint, form: fields ?? {} });
|
|
276
|
+
return client.parse(response, endpoint);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── connectivity and spaces ──────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Probe the desktop client proxy.
|
|
283
|
+
*
|
|
284
|
+
* @returns {Promise<boolean>} true when the proxy answers below 500.
|
|
285
|
+
*/
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Uploads: single-request create plus the desktop client's sliced protocol for anything bigger.
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-zspace/client
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import fsp from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { UPLOAD_FATAL_CODES, ZSpaceError } from "./errors.js";
|
|
11
|
+
import { percentEncode } from "./transport.js";
|
|
12
|
+
import { joinRemote } from "../format.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Upload a local file into a remote directory.
|
|
16
|
+
*
|
|
17
|
+
* Files at or below `smallUploadMaxBytes` use `/v2/file/create`; larger ones
|
|
18
|
+
* (and any request the local proxy rejects with HTTP 413) use the desktop
|
|
19
|
+
* client's sliced `/v2/file/upload` protocol.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} localPath - local file.
|
|
22
|
+
* @param {string} remoteDir - absolute remote directory.
|
|
23
|
+
* @param {{name?: string, onProgress?: (sent: number, total: number) => void}} [options] - upload options.
|
|
24
|
+
* @returns {Promise<{remotePath: string, bytes: number, method: "create"|"sliced"}>} upload result.
|
|
25
|
+
*/
|
|
26
|
+
export async function upload(client, localPath, remoteDir, options = {}) {
|
|
27
|
+
const stat = await fsp.stat(localPath);
|
|
28
|
+
if (!stat.isFile()) throw new ZSpaceError("ENOTFILE", `本地路径不是文件:${localPath}`);
|
|
29
|
+
const name = options.name || path.basename(localPath);
|
|
30
|
+
const target = joinRemote(remoteDir, name);
|
|
31
|
+
|
|
32
|
+
if (stat.size > client.smallUploadMaxBytes) {
|
|
33
|
+
await client.uploadSliced(localPath, target, stat.size, stat, options.onProgress);
|
|
34
|
+
return { remotePath: target, bytes: stat.size, method: "sliced" };
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const response = await client.send({
|
|
38
|
+
method: "POST",
|
|
39
|
+
endpoint: "/v2/file/create",
|
|
40
|
+
headers: { "Content-Type": "application/octet-stream", path: percentEncode(target) },
|
|
41
|
+
filePath: localPath,
|
|
42
|
+
});
|
|
43
|
+
client.parse(response, "/v2/file/create");
|
|
44
|
+
options.onProgress?.(stat.size, stat.size);
|
|
45
|
+
return { remotePath: target, bytes: stat.size, method: "create" };
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error instanceof ZSpaceError && error.code === "HTTP413") {
|
|
48
|
+
await client.uploadSliced(localPath, target, stat.size, stat, options.onProgress);
|
|
49
|
+
return { remotePath: target, bytes: stat.size, method: "sliced" };
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Sliced upload (`/v2/file/upload`), the protocol the desktop client uses
|
|
57
|
+
* for anything the local proxy will not pass through in one request.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} localPath - local file.
|
|
60
|
+
* @param {string} target - absolute remote file path.
|
|
61
|
+
* @param {number} total - total byte size.
|
|
62
|
+
* @param {import("node:fs").Stats} stat - local file stat.
|
|
63
|
+
* @param {(sent: number, total: number) => void} [onProgress] - progress callback.
|
|
64
|
+
* @returns {Promise<void>} resolves when the last slice was accepted.
|
|
65
|
+
*/
|
|
66
|
+
export async function uploadSliced(client, localPath, target, total, stat, onProgress) {
|
|
67
|
+
const creds = client.credentials;
|
|
68
|
+
const mtimeMs = Math.ceil(stat.mtimeMs);
|
|
69
|
+
const uuid = crypto.createHash("md5").update(`${mtimeMs + total}${target}`).digest("hex");
|
|
70
|
+
const modifyTime = Math.ceil(mtimeMs / 1000);
|
|
71
|
+
const handle = await fsp.open(localPath, "r");
|
|
72
|
+
try {
|
|
73
|
+
let sent = 0;
|
|
74
|
+
while (sent < total) {
|
|
75
|
+
const length = Math.min(client.sliceSize, total - sent);
|
|
76
|
+
const buffer = Buffer.alloc(length);
|
|
77
|
+
await handle.read(buffer, 0, length, sent);
|
|
78
|
+
const params = {
|
|
79
|
+
app: "file",
|
|
80
|
+
path: percentEncode(target),
|
|
81
|
+
size: total,
|
|
82
|
+
uuid,
|
|
83
|
+
seek: sent,
|
|
84
|
+
crtime: "",
|
|
85
|
+
modify_time: modifyTime,
|
|
86
|
+
rename: 0,
|
|
87
|
+
token: percentEncode(creds.token),
|
|
88
|
+
plat: "pc",
|
|
89
|
+
nasid: creds.nasId,
|
|
90
|
+
version: creds.appVersion,
|
|
91
|
+
device_id: creds.deviceId,
|
|
92
|
+
device: percentEncode(creds.device),
|
|
93
|
+
"request-purpose": 4,
|
|
94
|
+
"remote-port": 8050,
|
|
95
|
+
split: 1,
|
|
96
|
+
};
|
|
97
|
+
const headers = Object.fromEntries(Object.entries(params).map(([key, value]) => [key, String(value)]));
|
|
98
|
+
headers.Cookie = Object.entries(params)
|
|
99
|
+
.map(([key, value]) => `${key === "nasid" ? "nas_id" : key}=${percentEncode(value)}`)
|
|
100
|
+
.join("; ");
|
|
101
|
+
headers["Content-Type"] = "application/octet-stream";
|
|
102
|
+
headers["Content-Length"] = String(length);
|
|
103
|
+
const url = `${client.baseUrl}/v2/file/upload?remote_port=8050&drnd=${Date.now()}&uuid=${uuid}`;
|
|
104
|
+
await client.retrying(
|
|
105
|
+
async () => {
|
|
106
|
+
const response = await client.send({ method: "POST", url, headers, body: buffer });
|
|
107
|
+
client.parse(response, "/v2/file/upload");
|
|
108
|
+
},
|
|
109
|
+
error => !(error instanceof ZSpaceError && UPLOAD_FATAL_CODES.has(error.code)),
|
|
110
|
+
);
|
|
111
|
+
sent += length;
|
|
112
|
+
onProgress?.(sent, total);
|
|
113
|
+
}
|
|
114
|
+
} finally {
|
|
115
|
+
await handle.close();
|
|
116
|
+
}
|
|
117
|
+
}
|