dsh-zspace 0.1.1 → 0.3.0
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 +21 -0
- package/README.md +29 -4
- package/cordis.patch.yml +16 -2
- package/lib/README.md +6 -3
- package/lib/client/browse.js +0 -7
- package/lib/client/download.js +0 -15
- package/lib/client/mutate.js +0 -31
- package/lib/client/relay.js +30 -0
- package/lib/client/router.js +132 -0
- package/lib/client/session.js +0 -7
- package/lib/client/spaces.js +2 -32
- package/lib/client/transport.js +0 -50
- package/lib/client/upload.js +0 -12
- package/lib/client/webdav.js +622 -0
- package/lib/client.js +55 -28
- package/lib/config.js +38 -0
- package/lib/index.js +7 -0
- package/lib/tools/ls.js +1 -1
- package/lib/tools/status.js +21 -5
- package/lib/tools/write.js +73 -0
- package/lib/tools.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebDAV transport — the same-network fast path.
|
|
3
|
+
*
|
|
4
|
+
* When the machine can reach the NAS directly (typical on the same LAN), every
|
|
5
|
+
* file operation can go over the NAS's built-in WebDAV service
|
|
6
|
+
* (`http://<nas>:5005/`) instead of the desktop client's cloud relay: lower
|
|
7
|
+
* latency, no dependency on the client being logged in, and directory listing
|
|
8
|
+
* without the relay's 50-row page limit.
|
|
9
|
+
*
|
|
10
|
+
* The plugin's tools speak **NAS paths** (`/sata1/my/data/...`), so this module
|
|
11
|
+
* translates between those and the WebDAV layout:
|
|
12
|
+
*
|
|
13
|
+
* ```
|
|
14
|
+
* NAS /sata1/my/data/相册/a.jpg <-> WebDAV <webdavHomePath>/相册/a.jpg
|
|
15
|
+
* NAS /sata1/public/img/b.png <-> WebDAV <webdavPublicPath>/img/b.png
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* The NAS-side roots come from config (`homePath` / `publicPath`) or, failing
|
|
19
|
+
* that, a one-off relay lookup — so results look identical no matter which
|
|
20
|
+
* transport served them.
|
|
21
|
+
*
|
|
22
|
+
* @module dsh-zspace/client/webdav
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import fsp from "node:fs/promises";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { Readable } from "node:stream";
|
|
29
|
+
import { pipeline } from "node:stream/promises";
|
|
30
|
+
|
|
31
|
+
import { joinRemote, toEntry } from "../format.js";
|
|
32
|
+
import { ZSpaceError } from "./errors.js";
|
|
33
|
+
|
|
34
|
+
/** Environment variable carrying the WebDAV password (kept out of config files). */
|
|
35
|
+
export const PASSWORD_ENV = "ZS_WEBDAV_PASSWORD";
|
|
36
|
+
|
|
37
|
+
/** Environment variable carrying the WebDAV user name. */
|
|
38
|
+
export const USER_ENV = "ZS_WEBDAV_USER";
|
|
39
|
+
|
|
40
|
+
export const name = "webdav";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve WebDAV credentials: environment first, config as a fallback.
|
|
44
|
+
*
|
|
45
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
46
|
+
* @returns {{user: string, password: string, complete: boolean}} credentials.
|
|
47
|
+
*/
|
|
48
|
+
export function credentials(client) {
|
|
49
|
+
const user = process.env[USER_ENV] || client.webdavUser || "";
|
|
50
|
+
const password = process.env[PASSWORD_ENV] || client.webdavPassword || "";
|
|
51
|
+
return { user, password, complete: Boolean(user && password) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether this transport has everything it needs to be used at all.
|
|
56
|
+
*
|
|
57
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
58
|
+
* @returns {boolean} true when a URL and complete credentials are configured.
|
|
59
|
+
*/
|
|
60
|
+
export function configured(client) {
|
|
61
|
+
return Boolean(String(client.webdavUrl ?? "").trim()) && credentials(client).complete;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Base URL without a trailing slash.
|
|
66
|
+
*
|
|
67
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
68
|
+
* @returns {string} base URL.
|
|
69
|
+
*/
|
|
70
|
+
function baseUrl(client) {
|
|
71
|
+
return String(client.webdavUrl ?? "").replace(/\/+$/, "");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Percent-encode every path segment (keeps the separators).
|
|
76
|
+
*
|
|
77
|
+
* @param {string} remotePath - DAV path.
|
|
78
|
+
* @returns {string} encoded path.
|
|
79
|
+
*/
|
|
80
|
+
function encodePath(remotePath) {
|
|
81
|
+
return String(remotePath)
|
|
82
|
+
.split("/")
|
|
83
|
+
.map(segment => encodeURIComponent(segment))
|
|
84
|
+
.join("/");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Join two DAV paths with single slashes.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} base - base path.
|
|
91
|
+
* @param {string} rest - remainder.
|
|
92
|
+
* @returns {string} joined path, always starting with `/`.
|
|
93
|
+
*/
|
|
94
|
+
function joinDav(base, rest) {
|
|
95
|
+
const left = `/${String(base).replace(/^\/+|\/+$/g, "")}`;
|
|
96
|
+
const right = String(rest).replace(/^\/+/, "");
|
|
97
|
+
const joined = right === "" ? left : `${left === "/" ? "" : left}/${right}`;
|
|
98
|
+
return joined.startsWith("/") ? joined : `/${joined}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One HTTP request against the WebDAV endpoint.
|
|
103
|
+
*
|
|
104
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
105
|
+
* @param {object} options - request options.
|
|
106
|
+
* @param {string} options.method - HTTP method (PROPFIND/MKCOL/MOVE/... allowed).
|
|
107
|
+
* @param {string} options.davPath - DAV path (already NAS-relative, not encoded).
|
|
108
|
+
* @param {Record<string, string>} [options.headers] - extra headers.
|
|
109
|
+
* @param {BodyInit|null} [options.body] - request body.
|
|
110
|
+
* @param {number} [options.timeoutMs] - per-request timeout.
|
|
111
|
+
* @returns {Promise<Response>} fetch response.
|
|
112
|
+
*/
|
|
113
|
+
async function request(client, { method, davPath, headers = {}, body = null, timeoutMs }) {
|
|
114
|
+
const { user, password } = credentials(client);
|
|
115
|
+
const authorization = `Basic ${Buffer.from(`${user}:${password}`, "utf8").toString("base64")}`;
|
|
116
|
+
const init = {
|
|
117
|
+
method,
|
|
118
|
+
headers: { Authorization: authorization, ...headers },
|
|
119
|
+
signal: AbortSignal.timeout(timeoutMs ?? client.timeoutMs ?? 60_000),
|
|
120
|
+
};
|
|
121
|
+
if (body !== null) {
|
|
122
|
+
init.body = body;
|
|
123
|
+
init.duplex = "half";
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
return await fetch(`${baseUrl(client)}${encodePath(davPath) || "/"}`, init);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
const timedOut = error?.name === "TimeoutError" || error?.name === "AbortError";
|
|
129
|
+
throw new ZSpaceError(
|
|
130
|
+
timedOut ? "ETIMEDOUT" : "EDAV",
|
|
131
|
+
timedOut ? `WebDAV 请求超时:${method} ${davPath}` : `WebDAV 请求失败(${method} ${davPath}):${error?.message ?? error}`,
|
|
132
|
+
{ endpoint: davPath, cause: error },
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Turn a non-2xx response into a ZSpaceError with an actionable message.
|
|
139
|
+
*
|
|
140
|
+
* @param {Response} response - fetch response.
|
|
141
|
+
* @param {string} action - what was attempted (for the message).
|
|
142
|
+
* @returns {Promise<void>} resolves for success statuses.
|
|
143
|
+
* @throws {ZSpaceError} for failures.
|
|
144
|
+
*/
|
|
145
|
+
async function assertOk(response, action) {
|
|
146
|
+
if (response.ok || response.status === 207) return;
|
|
147
|
+
const status = response.status;
|
|
148
|
+
const hints = {
|
|
149
|
+
401: "WebDAV 凭据被拒:检查 webdavUser 与 ZS_WEBDAV_PASSWORD(极空间用 NAS 账号密码,不是桌面客户端 token)",
|
|
150
|
+
403: "WebDAV 无权限:确认该账号对该目录可读写",
|
|
151
|
+
404: "路径不存在:先 zspace_ls 上级目录确认拼写",
|
|
152
|
+
405: "目标已存在或方法不被支持",
|
|
153
|
+
409: "父目录不存在或状态冲突:先建父目录",
|
|
154
|
+
507: "NAS 空间不足",
|
|
155
|
+
};
|
|
156
|
+
throw new ZSpaceError(`HTTP${status}`, `${action} 失败:HTTP ${status}${hints[status] ? ` — ${hints[status]}` : ""}`, { endpoint: action });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Local name of a DAV href, decoded and stripped down to a DAV path.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} href - raw href from a PROPFIND response.
|
|
163
|
+
* @returns {string} decoded DAV path.
|
|
164
|
+
*/
|
|
165
|
+
function hrefToDavPath(href) {
|
|
166
|
+
let value = String(href ?? "");
|
|
167
|
+
try {
|
|
168
|
+
value = new URL(value, "http://placeholder").pathname;
|
|
169
|
+
} catch {
|
|
170
|
+
/* keep raw value */
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
value = decodeURIComponent(value);
|
|
174
|
+
} catch {
|
|
175
|
+
/* keep encoded value */
|
|
176
|
+
}
|
|
177
|
+
if (value.length > 1) value = value.replace(/\/+$/, "");
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Parse a PROPFIND multistatus body without a full XML dependency.
|
|
183
|
+
*
|
|
184
|
+
* Namespace prefixes differ between servers (`D:`, `d:`, `lp1:`), so every tag
|
|
185
|
+
* is matched by its local name.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} xml - response body.
|
|
188
|
+
* @returns {Array<{href: string, name: string, dir: boolean, size: number, modified: string}>} parsed entries.
|
|
189
|
+
*/
|
|
190
|
+
export function parseMultiStatus(xml) {
|
|
191
|
+
const entries = [];
|
|
192
|
+
for (const block of String(xml).matchAll(/<(?:[\w-]+:)?response\b[\s\S]*?<\/(?:[\w-]+:)?response>/gi)) {
|
|
193
|
+
const chunk = block[0];
|
|
194
|
+
const href = /<(?:[\w-]+:)?href[^>]*>([\s\S]*?)<\/(?:[\w-]+:)?href>/i.exec(chunk)?.[1] ?? "";
|
|
195
|
+
const type = /<(?:[\w-]+:)?resourcetype[^>]*>([\s\S]*?)<\/(?:[\w-]+:)?resourcetype>/i.exec(chunk)?.[1] ?? "";
|
|
196
|
+
const size = /<(?:[\w-]+:)?getcontentlength[^>]*>(\d*)</i.exec(chunk)?.[1] ?? "0";
|
|
197
|
+
const modified = /<(?:[\w-]+:)?getlastmodified[^>]*>([\s\S]*?)</i.exec(chunk)?.[1] ?? "";
|
|
198
|
+
const davPath = hrefToDavPath(href);
|
|
199
|
+
const stamp = Date.parse(modified);
|
|
200
|
+
entries.push({
|
|
201
|
+
href: davPath,
|
|
202
|
+
name: davPath === "/" ? "/" : davPath.slice(davPath.lastIndexOf("/") + 1),
|
|
203
|
+
dir: /collection/i.test(type),
|
|
204
|
+
size: Number(size) || 0,
|
|
205
|
+
modified: Number.isFinite(stamp) ? String(Math.floor(stamp / 1000)) : "",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return entries;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Translate a NAS path the tools use into a WebDAV path.
|
|
213
|
+
*
|
|
214
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
215
|
+
* @param {string} nasPath - NAS path such as `/sata1/my/data/a.txt`.
|
|
216
|
+
* @returns {Promise<string>} DAV path.
|
|
217
|
+
* @throws {ZSpaceError} when the path is outside both known roots.
|
|
218
|
+
*/
|
|
219
|
+
export async function toDavPath(client, nasPath) {
|
|
220
|
+
const roots = await nasRoots(client);
|
|
221
|
+
const homeDav = client.webdavHomePath || "/";
|
|
222
|
+
const publicDav = client.webdavPublicPath || "";
|
|
223
|
+
const value = String(nasPath);
|
|
224
|
+
if (roots.home && (value === roots.home || value.startsWith(`${roots.home}/`))) {
|
|
225
|
+
return joinDav(homeDav, value.slice(roots.home.length));
|
|
226
|
+
}
|
|
227
|
+
if (publicDav && roots.public && (value === roots.public || value.startsWith(`${roots.public}/`))) {
|
|
228
|
+
return joinDav(publicDav, value.slice(roots.public.length));
|
|
229
|
+
}
|
|
230
|
+
throw new ZSpaceError(
|
|
231
|
+
"DAV_PATH",
|
|
232
|
+
`WebDAV 通道无法映射该路径(不在个人/公共空间根下):${value}。可在配置里写死 homePath / publicPath,或改用 home: / public: 前缀。`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Translate a WebDAV path back into the NAS path shape the tools expect.
|
|
238
|
+
*
|
|
239
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
240
|
+
* @param {string} davPath - DAV path.
|
|
241
|
+
* @returns {Promise<string>} NAS path.
|
|
242
|
+
*/
|
|
243
|
+
export async function fromDavPath(client, davPath) {
|
|
244
|
+
const roots = await nasRoots(client);
|
|
245
|
+
const homeDav = (client.webdavHomePath || "/").replace(/\/+$/, "") || "/";
|
|
246
|
+
const publicDav = (client.webdavPublicPath || "").replace(/\/+$/, "");
|
|
247
|
+
const value = davPath === "/" ? "/" : davPath.replace(/\/+$/, "");
|
|
248
|
+
if (publicDav && (value === publicDav || value.startsWith(`${publicDav}/`))) {
|
|
249
|
+
return joinRemote(roots.public || "/public", value.slice(publicDav.length));
|
|
250
|
+
}
|
|
251
|
+
const rest = homeDav === "/" ? value : value.slice(homeDav.length);
|
|
252
|
+
return joinRemote(roots.home || "", rest);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* NAS-side roots, resolved once: config first, then a relay lookup.
|
|
257
|
+
*
|
|
258
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
259
|
+
* @returns {Promise<{home: string, public: string}>} NAS roots ("" when unknown).
|
|
260
|
+
*/
|
|
261
|
+
export async function nasRoots(client) {
|
|
262
|
+
if (client._davRoots) return client._davRoots;
|
|
263
|
+
let home = client.configuredHomePath || client._homePath || "";
|
|
264
|
+
let publicSpace = client.configuredPublicPath || client._publicPath || "";
|
|
265
|
+
if (!home || !publicSpace) {
|
|
266
|
+
// The relay knows the real roots; one lookup is cheaper than making the
|
|
267
|
+
// user restate them. It is fine for this to fail — the caller then gets a
|
|
268
|
+
// clear "set homePath/publicPath" error.
|
|
269
|
+
const relay = await import("./relay.js");
|
|
270
|
+
if (!home) home = await relay.homePath(client).catch(() => "");
|
|
271
|
+
if (!publicSpace) publicSpace = await relay.publicPath(client).catch(() => "");
|
|
272
|
+
}
|
|
273
|
+
client._davRoots = { home, public: publicSpace };
|
|
274
|
+
return client._davRoots;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Reachability + credential probe, used by the router.
|
|
279
|
+
*
|
|
280
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
281
|
+
* @returns {Promise<{reachable: boolean, usable: boolean, status?: number, reason: string}>} probe result.
|
|
282
|
+
*/
|
|
283
|
+
export async function probe(client) {
|
|
284
|
+
if (!String(client.webdavUrl ?? "").trim()) {
|
|
285
|
+
return { reachable: false, usable: false, reason: "未配置 webdavUrl" };
|
|
286
|
+
}
|
|
287
|
+
if (!credentials(client).complete) {
|
|
288
|
+
return { reachable: false, usable: false, reason: `缺少 WebDAV 凭据(设置 ${USER_ENV} 与 ${PASSWORD_ENV})` };
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const response = await request(client, {
|
|
292
|
+
method: "PROPFIND",
|
|
293
|
+
davPath: "/",
|
|
294
|
+
headers: { Depth: "0" },
|
|
295
|
+
timeoutMs: client.webdavProbeTimeoutMs ?? 1500,
|
|
296
|
+
});
|
|
297
|
+
if (response.status === 401 || response.status === 403) {
|
|
298
|
+
return { reachable: true, usable: false, status: response.status, reason: `可达但凭据被拒(HTTP ${response.status})` };
|
|
299
|
+
}
|
|
300
|
+
if (response.status >= 500) {
|
|
301
|
+
return { reachable: true, usable: false, status: response.status, reason: `服务端错误(HTTP ${response.status})` };
|
|
302
|
+
}
|
|
303
|
+
return { reachable: true, usable: true, status: response.status, reason: `可达(HTTP ${response.status})` };
|
|
304
|
+
} catch (error) {
|
|
305
|
+
return {
|
|
306
|
+
reachable: false,
|
|
307
|
+
usable: false,
|
|
308
|
+
reason: error?.code === "ETIMEDOUT" ? "探测超时(多半不在同一网络)" : `不可达:${error?.message ?? error}`,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Connectivity in the shape the tools expect.
|
|
315
|
+
*
|
|
316
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
317
|
+
* @returns {Promise<boolean>} whether WebDAV is usable right now.
|
|
318
|
+
*/
|
|
319
|
+
export async function check(client) {
|
|
320
|
+
return (await probe(client)).usable;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Storage pools are a relay-only concept (WebDAV does not expose them).
|
|
325
|
+
*
|
|
326
|
+
* @returns {Promise<Array<never>>} always empty.
|
|
327
|
+
*/
|
|
328
|
+
export async function pools() {
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Whether a path exists and is a collection.
|
|
334
|
+
*
|
|
335
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
336
|
+
* @param {string} nasPath - NAS path.
|
|
337
|
+
* @returns {Promise<boolean>} true for existing directories.
|
|
338
|
+
*/
|
|
339
|
+
export async function isDirectory(client, nasPath) {
|
|
340
|
+
try {
|
|
341
|
+
const entry = await info(client, nasPath);
|
|
342
|
+
return entry.dir === true;
|
|
343
|
+
} catch {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* NAS path of the personal-space root.
|
|
350
|
+
*
|
|
351
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
352
|
+
* @returns {Promise<string>} NAS path.
|
|
353
|
+
*/
|
|
354
|
+
export async function homePath(client) {
|
|
355
|
+
const roots = await nasRoots(client);
|
|
356
|
+
if (!roots.home) throw new ZSpaceError("NOHOME", "WebDAV 模式下无法确定个人空间根:请在配置里设置 homePath");
|
|
357
|
+
return roots.home;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* NAS path of the public-space root.
|
|
362
|
+
*
|
|
363
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
364
|
+
* @returns {Promise<string>} NAS path.
|
|
365
|
+
*/
|
|
366
|
+
export async function publicPath(client) {
|
|
367
|
+
const roots = await nasRoots(client);
|
|
368
|
+
if (!roots.public) throw new ZSpaceError("NOPUBLIC", "WebDAV 模式下无法确定公共空间根:请在配置里设置 publicPath(或它在 WebDAV 里不可见)");
|
|
369
|
+
return roots.public;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* List one directory (single PROPFIND, no 50-row paging).
|
|
374
|
+
*
|
|
375
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
376
|
+
* @param {string} nasPath - NAS directory.
|
|
377
|
+
* @param {{showHidden?: boolean, maxEntries?: number}} [options] - listing options.
|
|
378
|
+
* @returns {Promise<{entries: Array<ReturnType<typeof toEntry>>, truncated: boolean}>} listing.
|
|
379
|
+
*/
|
|
380
|
+
export async function list(client, nasPath, options = {}) {
|
|
381
|
+
const davPath = await toDavPath(client, nasPath);
|
|
382
|
+
const response = await request(client, { method: "PROPFIND", davPath, headers: { Depth: "1" } });
|
|
383
|
+
await assertOk(response, `PROPFIND ${nasPath}`);
|
|
384
|
+
const xml = await response.text();
|
|
385
|
+
const self = davPath === "/" ? "/" : davPath.replace(/\/+$/, "");
|
|
386
|
+
const maxEntries = options.maxEntries ?? client.listMaxEntries ?? 2000;
|
|
387
|
+
const entries = [];
|
|
388
|
+
let truncated = false;
|
|
389
|
+
for (const raw of parseMultiStatus(xml)) {
|
|
390
|
+
if (raw.href === self || raw.href === `${self}/`) continue;
|
|
391
|
+
if (!raw.name) continue;
|
|
392
|
+
if (!options.showHidden && raw.name.startsWith(".")) continue;
|
|
393
|
+
if (entries.length >= maxEntries) {
|
|
394
|
+
truncated = true;
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
const nas = await fromDavPath(client, raw.href);
|
|
398
|
+
entries.push(
|
|
399
|
+
toEntry({
|
|
400
|
+
name: raw.name,
|
|
401
|
+
path: nas,
|
|
402
|
+
is_dir: raw.dir ? "1" : "0",
|
|
403
|
+
size: String(raw.size),
|
|
404
|
+
modify_time: raw.modified,
|
|
405
|
+
}),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
entries.sort((a, b) => Number(b.dir) - Number(a.dir) || a.name.localeCompare(b.name, "zh"));
|
|
409
|
+
return { entries, truncated };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Metadata for one path.
|
|
414
|
+
*
|
|
415
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
416
|
+
* @param {string} nasPath - NAS path.
|
|
417
|
+
* @returns {Promise<ReturnType<typeof toEntry>>} normalized entry.
|
|
418
|
+
*/
|
|
419
|
+
export async function info(client, nasPath) {
|
|
420
|
+
const davPath = await toDavPath(client, nasPath);
|
|
421
|
+
const response = await request(client, { method: "PROPFIND", davPath, headers: { Depth: "0" } });
|
|
422
|
+
await assertOk(response, `PROPFIND ${nasPath}`);
|
|
423
|
+
const [first] = parseMultiStatus(await response.text());
|
|
424
|
+
if (!first) throw new ZSpaceError("N001315", `文件不存在(${nasPath})`);
|
|
425
|
+
return toEntry({
|
|
426
|
+
name: first.name === "/" ? davPath.slice(davPath.lastIndexOf("/") + 1) : first.name,
|
|
427
|
+
path: nasPath,
|
|
428
|
+
is_dir: first.dir ? "1" : "0",
|
|
429
|
+
size: String(first.size),
|
|
430
|
+
modify_time: first.modified,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Create a directory (MKCOL).
|
|
436
|
+
*
|
|
437
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
438
|
+
* @param {string} nasPath - NAS directory to create.
|
|
439
|
+
* @returns {Promise<ReturnType<typeof toEntry>>} created entry.
|
|
440
|
+
*/
|
|
441
|
+
export async function mkdir(client, nasPath) {
|
|
442
|
+
const davPath = await toDavPath(client, nasPath);
|
|
443
|
+
const response = await request(client, { method: "MKCOL", davPath });
|
|
444
|
+
if (response.status === 405) throw new ZSpaceError("N001212", `目录已存在:${nasPath}`);
|
|
445
|
+
await assertOk(response, `MKCOL ${nasPath}`);
|
|
446
|
+
return toEntry({ name: nasPath.slice(nasPath.lastIndexOf("/") + 1), path: nasPath, is_dir: "1", size: "0" });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Rename in place (MOVE within the same directory).
|
|
451
|
+
*
|
|
452
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
453
|
+
* @param {string} nasPath - NAS path to rename.
|
|
454
|
+
* @param {string} newName - new base name.
|
|
455
|
+
* @returns {Promise<ReturnType<typeof toEntry>>} renamed entry.
|
|
456
|
+
*/
|
|
457
|
+
export async function rename(client, nasPath, newName) {
|
|
458
|
+
const parent = nasPath.slice(0, nasPath.lastIndexOf("/"));
|
|
459
|
+
const target = `${parent}/${newName}`;
|
|
460
|
+
await movePath(client, nasPath, target);
|
|
461
|
+
return toEntry({ name: newName, path: target, is_dir: "0", size: "0" });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* MOVE one NAS path to an absolute destination (used by rename/move).
|
|
466
|
+
*
|
|
467
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
468
|
+
* @param {string} from - source NAS path.
|
|
469
|
+
* @param {string} to - destination NAS path.
|
|
470
|
+
* @returns {Promise<void>} resolves when accepted.
|
|
471
|
+
*/
|
|
472
|
+
async function movePath(client, from, to) {
|
|
473
|
+
const source = await toDavPath(client, from);
|
|
474
|
+
const destination = await toDavPath(client, to);
|
|
475
|
+
const response = await request(client, {
|
|
476
|
+
method: "MOVE",
|
|
477
|
+
davPath: source,
|
|
478
|
+
headers: { Destination: `${baseUrl(client)}${encodePath(destination)}`, Overwrite: "F" },
|
|
479
|
+
});
|
|
480
|
+
if (response.status === 412) throw new ZSpaceError("N001212", `目标已存在:${to}`);
|
|
481
|
+
await assertOk(response, `MOVE ${from} -> ${to}`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Move several paths into a target directory.
|
|
486
|
+
*
|
|
487
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
488
|
+
* @param {string[]} nasPaths - sources.
|
|
489
|
+
* @param {string} to - destination directory.
|
|
490
|
+
* @returns {Promise<void>} resolves when accepted.
|
|
491
|
+
*/
|
|
492
|
+
export async function move(client, nasPaths, to) {
|
|
493
|
+
for (const source of nasPaths) {
|
|
494
|
+
const name = source.slice(source.lastIndexOf("/") + 1);
|
|
495
|
+
await movePath(client, source, `${to.replace(/\/+$/, "")}/${name}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Copy several paths into a target directory.
|
|
501
|
+
*
|
|
502
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
503
|
+
* @param {string[]} nasPaths - sources.
|
|
504
|
+
* @param {string} to - destination directory.
|
|
505
|
+
* @returns {Promise<void>} resolves when accepted.
|
|
506
|
+
*/
|
|
507
|
+
export async function copy(client, nasPaths, to) {
|
|
508
|
+
for (const source of nasPaths) {
|
|
509
|
+
const name = source.slice(source.lastIndexOf("/") + 1);
|
|
510
|
+
const from = await toDavPath(client, source);
|
|
511
|
+
const target = await toDavPath(client, `${to.replace(/\/+$/, "")}/${name}`);
|
|
512
|
+
const response = await request(client, {
|
|
513
|
+
method: "COPY",
|
|
514
|
+
davPath: from,
|
|
515
|
+
headers: { Destination: `${baseUrl(client)}${encodePath(target)}`, Overwrite: "F" },
|
|
516
|
+
});
|
|
517
|
+
if (response.status === 412) throw new ZSpaceError("N001212", `目标已存在:${to}/${name}`);
|
|
518
|
+
await assertOk(response, `COPY ${source} -> ${to}`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Delete paths (WebDAV DELETE — recycle-bin behaviour is the NAS's call).
|
|
524
|
+
*
|
|
525
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
526
|
+
* @param {string[]} nasPaths - paths to delete.
|
|
527
|
+
* @returns {Promise<void>} resolves when accepted.
|
|
528
|
+
*/
|
|
529
|
+
export async function remove(client, nasPaths) {
|
|
530
|
+
for (const target of nasPaths) {
|
|
531
|
+
const davPath = await toDavPath(client, target);
|
|
532
|
+
const response = await request(client, { method: "DELETE", davPath });
|
|
533
|
+
await assertOk(response, `DELETE ${target}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Download a file to a local directory.
|
|
539
|
+
*
|
|
540
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
541
|
+
* @param {string} nasPath - NAS file.
|
|
542
|
+
* @param {string} localDir - local destination directory.
|
|
543
|
+
* @param {{name?: string}} [options] - name override.
|
|
544
|
+
* @returns {Promise<{localPath: string, bytes: number}>} written file.
|
|
545
|
+
*/
|
|
546
|
+
export async function download(client, nasPath, localDir, options = {}) {
|
|
547
|
+
const davPath = await toDavPath(client, nasPath);
|
|
548
|
+
const response = await request(client, { method: "GET", davPath });
|
|
549
|
+
await assertOk(response, `GET ${nasPath}`);
|
|
550
|
+
if (!response.body) throw new ZSpaceError("EDAV", `WebDAV 未返回内容:${nasPath}`);
|
|
551
|
+
const dest = path.join(localDir, options.name || path.posix.basename(nasPath));
|
|
552
|
+
await fsp.mkdir(localDir, { recursive: true });
|
|
553
|
+
try {
|
|
554
|
+
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(dest));
|
|
555
|
+
} catch (error) {
|
|
556
|
+
await fsp.rm(dest, { force: true });
|
|
557
|
+
throw new ZSpaceError("EDOWNLOAD", `写入本地文件失败:${dest}(${error.message})`, { cause: error });
|
|
558
|
+
}
|
|
559
|
+
const stat = await fsp.stat(dest);
|
|
560
|
+
return { localPath: dest, bytes: stat.size };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Read the head of a file into memory.
|
|
565
|
+
*
|
|
566
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
567
|
+
* @param {string} nasPath - NAS file.
|
|
568
|
+
* @param {{maxBytes?: number}} [options] - byte cap.
|
|
569
|
+
* @returns {Promise<{buffer: Buffer, truncated: boolean, bytes: number}>} content.
|
|
570
|
+
*/
|
|
571
|
+
export async function readFile(client, nasPath, options = {}) {
|
|
572
|
+
const maxBytes = options.maxBytes ?? 262_144;
|
|
573
|
+
const davPath = await toDavPath(client, nasPath);
|
|
574
|
+
const response = await request(client, { method: "GET", davPath, headers: { Range: `bytes=0-${maxBytes - 1}` } });
|
|
575
|
+
await assertOk(response, `GET ${nasPath}`);
|
|
576
|
+
if (!response.body) return { buffer: Buffer.alloc(0), truncated: false, bytes: 0 };
|
|
577
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
578
|
+
const truncated = response.status === 206 ? buffer.length >= maxBytes : buffer.length > maxBytes;
|
|
579
|
+
return { buffer: buffer.subarray(0, maxBytes), truncated, bytes: Math.min(buffer.length, maxBytes) };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Upload a local file (single PUT).
|
|
584
|
+
*
|
|
585
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
586
|
+
* @param {string} localPath - local file.
|
|
587
|
+
* @param {string} remoteDir - NAS destination directory.
|
|
588
|
+
* @param {{name?: string}} [options] - name override.
|
|
589
|
+
* @returns {Promise<{remotePath: string, bytes: number, method: "put"}>} upload result.
|
|
590
|
+
*/
|
|
591
|
+
export async function upload(client, localPath, remoteDir, options = {}) {
|
|
592
|
+
const stat = await fsp.stat(localPath);
|
|
593
|
+
const name = options.name || path.basename(localPath);
|
|
594
|
+
const target = joinRemote(remoteDir, name);
|
|
595
|
+
const davPath = await toDavPath(client, target);
|
|
596
|
+
const response = await request(client, {
|
|
597
|
+
method: "PUT",
|
|
598
|
+
davPath,
|
|
599
|
+
headers: { "Content-Type": "application/octet-stream", "Content-Length": String(stat.size) },
|
|
600
|
+
body: Readable.toWeb(fs.createReadStream(localPath)),
|
|
601
|
+
});
|
|
602
|
+
await assertOk(response, `PUT ${target}`);
|
|
603
|
+
return { remotePath: target, bytes: stat.size, method: "put" };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Not used by the WebDAV transport (PUT is never sliced).
|
|
608
|
+
*
|
|
609
|
+
* @returns {Promise<never>} always throws.
|
|
610
|
+
*/
|
|
611
|
+
export async function uploadSliced() {
|
|
612
|
+
throw new ZSpaceError("EDAV", "WebDAV 通道不使用分片上传");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Not used by the WebDAV transport (download() opens its own request).
|
|
617
|
+
*
|
|
618
|
+
* @returns {Promise<never>} always throws.
|
|
619
|
+
*/
|
|
620
|
+
export async function openDownload() {
|
|
621
|
+
throw new ZSpaceError("EDAV", "WebDAV 通道请使用 download()");
|
|
622
|
+
}
|