dsh-remote 0.8.72 → 0.8.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.en.md +470 -389
- package/README.md +529 -462
- package/cordis.patch.yml +9 -9
- package/lib/binding.js +172 -172
- package/lib/client.js +4039 -4022
- package/lib/credential.js +263 -263
- package/lib/errors.js +141 -141
- package/lib/exec-job.js +235 -235
- package/lib/file-reference.js +457 -457
- package/lib/forwards.js +258 -258
- package/lib/host-api.js +808 -0
- package/lib/hostkey.js +108 -108
- package/lib/http-transport.js +196 -196
- package/lib/ignore.js +202 -202
- package/lib/index.js +4923 -4541
- package/lib/instructions-dynamic.js +415 -415
- package/lib/openssh.js +879 -879
- package/lib/paths.js +252 -252
- package/lib/pool.js +963 -893
- package/lib/registry.js +296 -293
- package/lib/remote-fs.js +173 -173
- package/lib/remote-sandbox.js +183 -183
- package/lib/remote-shell-child.cjs +344 -305
- package/lib/remote-shell.js +542 -518
- package/lib/routes-fs.js +245 -245
- package/lib/routes-term.js +169 -169
- package/lib/search.js +235 -235
- package/lib/sftp-surface.js +119 -119
- package/lib/sshconfig.js +779 -705
- package/lib/sync.js +275 -275
- package/lib/tasks.js +95 -95
- package/lib/telemetry.js +213 -213
- package/lib/terminal.js +205 -205
- package/lib/tui/flow.js +130 -0
- package/lib/tui/format.js +332 -0
- package/lib/tui/i18n.js +344 -0
- package/lib/tui/index.js +322 -0
- package/lib/tui/provider.js +327 -0
- package/lib/tui/scene.js +996 -0
- package/lib/update.js +472 -472
- package/lib/web-attach.js +850 -850
- package/lib/workspace-cleanup.js +337 -0
- package/package.json +100 -99
package/lib/routes-fs.js
CHANGED
|
@@ -1,245 +1,245 @@
|
|
|
1
|
-
// Sidebar file routes: /ls /read /write /fs.
|
|
2
|
-
//
|
|
3
|
-
// Machine-scoped callers (workspace picker, settings) omit sessionId and use
|
|
4
|
-
// the active-machine pool. Session-scoped callers (explorer / file tab) MUST
|
|
5
|
-
// pass sessionId (or local=mirror cwd); those requests resolve through the
|
|
6
|
-
// same mirror binding as rw_* tools and never fall back to the active machine.
|
|
7
|
-
import { mkdirSync } from 'node:fs'
|
|
8
|
-
import path from 'node:path'
|
|
9
|
-
import {
|
|
10
|
-
normalizeRemotePath, joinRemotePath, remoteDirname, mkdirRemoteDirs, toDisplayPath,
|
|
11
|
-
} from './paths.js'
|
|
12
|
-
import { requestSessionHint } from './binding.js'
|
|
13
|
-
import { listDirStructured, listRemoteDrives, removeRemoteTree, previewRemoteFile } from './remote-fs.js'
|
|
14
|
-
|
|
15
|
-
export function createFsRoutes({
|
|
16
|
-
sendJson,
|
|
17
|
-
readBody,
|
|
18
|
-
resolveRequestBinding,
|
|
19
|
-
decodeBuf,
|
|
20
|
-
encodeText,
|
|
21
|
-
audit,
|
|
22
|
-
config,
|
|
23
|
-
mirrorDirFor,
|
|
24
|
-
}) {
|
|
25
|
-
const bindOrReply = async (req, res, body = {}) => {
|
|
26
|
-
try {
|
|
27
|
-
return await resolveRequestBinding(req, body)
|
|
28
|
-
} catch (err) {
|
|
29
|
-
sendJson(res, (err && err.httpStatus) || 500, { ok: false, error: String((err && err.message) || err) })
|
|
30
|
-
return null
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return [
|
|
35
|
-
{
|
|
36
|
-
kind: 'exact',
|
|
37
|
-
path: '/dsh-remote/ls',
|
|
38
|
-
handler: async (req, res) => {
|
|
39
|
-
try {
|
|
40
|
-
// A POST body counts. This route used to read the session hint from the
|
|
41
|
-
// QUERY STRING only, so `POST /ls {sessionId}` silently listed the ACTIVE
|
|
42
|
-
// machine instead of the caller's — the wrong-host hazard issue #25 is
|
|
43
|
-
// about, reachable through the API even though the browser always uses
|
|
44
|
-
// GET. `/read`, `/write` and `/fs` already honoured the body; now this one
|
|
45
|
-
// does too, and `path` may come from either place.
|
|
46
|
-
let body = {}
|
|
47
|
-
if (req.method === 'POST') {
|
|
48
|
-
try { body = JSON.parse((await readBody(req)) || '{}') } catch { body = {} }
|
|
49
|
-
}
|
|
50
|
-
const hint = requestSessionHint(req, body)
|
|
51
|
-
const b = await bindOrReply(req, res, hint)
|
|
52
|
-
if (!b) return
|
|
53
|
-
// Identity echo (external-switch defense): name the machine this
|
|
54
|
-
// listing is answered for, straight from the pool's target — the
|
|
55
|
-
// picker validates every listing against the machine it INTENDED and
|
|
56
|
-
// re-switches on mismatch (the settings page can change the active
|
|
57
|
-
// machine under a memoized picker).
|
|
58
|
-
const tgt = (b.pool && b.pool.config) || {}
|
|
59
|
-
const machine = {
|
|
60
|
-
host: String(tgt.host || b.host || ''),
|
|
61
|
-
port: Number(tgt.port) || Number(b.port) || 22,
|
|
62
|
-
username: String(tgt.username || b.username || ''),
|
|
63
|
-
}
|
|
64
|
-
await b.pool.detect()
|
|
65
|
-
const q = new URL(req.url, 'http://localhost').searchParams
|
|
66
|
-
const raw = q.get('path')
|
|
67
|
-
? decodeURIComponent(q.get('path'))
|
|
68
|
-
: (body.path ? String(body.path) : (b.ws || ''))
|
|
69
|
-
const canon = normalizeRemotePath(raw)
|
|
70
|
-
const win = b.pool.platform === 'windows'
|
|
71
|
-
if (canon === '/' || raw === '' || raw === '/') {
|
|
72
|
-
if (win || b.pool.platform !== 'posix') {
|
|
73
|
-
const drives = await listRemoteDrives(b.pool)
|
|
74
|
-
if (drives.length) return sendJson(res, 200, { path: '', platform: 'windows', items: drives, bound: b.bound, machine })
|
|
75
|
-
}
|
|
76
|
-
if (win) return sendJson(res, 200, { path: '', platform: 'windows', items: [], bound: b.bound, machine })
|
|
77
|
-
}
|
|
78
|
-
const sftp = await b.pool.sftp()
|
|
79
|
-
const out = await listDirStructured(sftp, canon)
|
|
80
|
-
const items = out.items.map((it) => ({
|
|
81
|
-
...it,
|
|
82
|
-
path: toDisplayPath(joinRemotePath(canon, it.name), b.pool.platform),
|
|
83
|
-
}))
|
|
84
|
-
return sendJson(res, 200, {
|
|
85
|
-
path: toDisplayPath(out.path, b.pool.platform),
|
|
86
|
-
platform: win ? 'windows' : 'posix',
|
|
87
|
-
items,
|
|
88
|
-
missing: !!out.missing,
|
|
89
|
-
bound: b.bound,
|
|
90
|
-
machine,
|
|
91
|
-
})
|
|
92
|
-
} catch (err) {
|
|
93
|
-
return sendJson(res, 500, { error: String((err && err.message) || err) })
|
|
94
|
-
}
|
|
95
|
-
},
|
|
96
|
-
},
|
|
97
|
-
{
|
|
98
|
-
kind: 'exact',
|
|
99
|
-
path: '/dsh-remote/read',
|
|
100
|
-
handler: async (req, res) => {
|
|
101
|
-
if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
102
|
-
try {
|
|
103
|
-
const q = new URL(req.url, 'http://localhost').searchParams
|
|
104
|
-
let p = q.get('path') ? decodeURIComponent(q.get('path')) : ''
|
|
105
|
-
let encoding = ''
|
|
106
|
-
let maxBytes = 256 * 1024
|
|
107
|
-
let body = {}
|
|
108
|
-
if (req.method === 'POST') {
|
|
109
|
-
body = JSON.parse((await readBody(req)) || '{}')
|
|
110
|
-
if (!p) p = String(body.path || '')
|
|
111
|
-
if (body.encoding) encoding = String(body.encoding)
|
|
112
|
-
if (body.maxBytes) maxBytes = Number(body.maxBytes)
|
|
113
|
-
}
|
|
114
|
-
if (q.get('maxBytes')) maxBytes = Number(q.get('maxBytes'))
|
|
115
|
-
if (!p) return sendJson(res, 400, { ok: false, error: 'path is required' })
|
|
116
|
-
const b = await bindOrReply(req, res, body)
|
|
117
|
-
if (!b) return
|
|
118
|
-
const target = normalizeRemotePath(p)
|
|
119
|
-
let sftp
|
|
120
|
-
try {
|
|
121
|
-
sftp = await b.pool.sftp()
|
|
122
|
-
} catch (err) {
|
|
123
|
-
return sendJson(res, 500, { ok: false, error: 'sftp unavailable: ' + ((err && err.message) || err) })
|
|
124
|
-
}
|
|
125
|
-
try {
|
|
126
|
-
const preview = await previewRemoteFile(sftp, target, {
|
|
127
|
-
maxBytes,
|
|
128
|
-
encoding: encoding || config.encoding,
|
|
129
|
-
decodeBuf,
|
|
130
|
-
})
|
|
131
|
-
return sendJson(res, 200, preview)
|
|
132
|
-
} catch (err) {
|
|
133
|
-
return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
|
|
134
|
-
}
|
|
135
|
-
} catch (err) {
|
|
136
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
137
|
-
}
|
|
138
|
-
},
|
|
139
|
-
},
|
|
140
|
-
{
|
|
141
|
-
kind: 'exact',
|
|
142
|
-
path: '/dsh-remote/write',
|
|
143
|
-
handler: async (req, res) => {
|
|
144
|
-
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
145
|
-
try {
|
|
146
|
-
const body = JSON.parse((await readBody(req)) || '{}')
|
|
147
|
-
const p = normalizeRemotePath(String(body.path || ''))
|
|
148
|
-
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path is required' })
|
|
149
|
-
const b = await bindOrReply(req, res, body)
|
|
150
|
-
if (!b) return
|
|
151
|
-
const sftp = await b.pool.sftp()
|
|
152
|
-
let st = null
|
|
153
|
-
try { st = await sftp.stat(p) } catch { /* new file */ }
|
|
154
|
-
if (body.expectedMtime != null && st && st.mtime !== Number(body.expectedMtime)) {
|
|
155
|
-
return sendJson(res, 409, { ok: false, error: `远端文件已变化(mtime ${st.mtime} ≠ ${body.expectedMtime}),已放弃保存——请重新读取后再编辑` })
|
|
156
|
-
}
|
|
157
|
-
const buf = encodeText(String(body.content ?? ''), body.encoding || config.encoding)
|
|
158
|
-
if (!st) await mkdirRemoteDirs(sftp, remoteDirname(p))
|
|
159
|
-
await sftp.writeFile(p, buf)
|
|
160
|
-
audit('write', `write ${p}`, 0, b)
|
|
161
|
-
const st2 = await sftp.stat(p).catch(() => null)
|
|
162
|
-
return sendJson(res, 200, { ok: true, bytes: buf.byteLength, mtime: st2 ? st2.mtime : Math.floor(Date.now() / 1000) })
|
|
163
|
-
} catch (err) {
|
|
164
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
165
|
-
}
|
|
166
|
-
},
|
|
167
|
-
},
|
|
168
|
-
{
|
|
169
|
-
kind: 'exact',
|
|
170
|
-
path: '/dsh-remote/fs',
|
|
171
|
-
handler: async (req, res) => {
|
|
172
|
-
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
173
|
-
try {
|
|
174
|
-
const body = JSON.parse((await readBody(req)) || '{}')
|
|
175
|
-
const op = String(body.op || '')
|
|
176
|
-
const b = await bindOrReply(req, res, body)
|
|
177
|
-
if (!b) return
|
|
178
|
-
const sftp = await b.pool.sftp()
|
|
179
|
-
if (op === 'mkdir') {
|
|
180
|
-
const p = normalizeRemotePath(String(body.path || ''))
|
|
181
|
-
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
|
|
182
|
-
await mkdirRemoteDirs(sftp, p)
|
|
183
|
-
audit('mkdir', `mkdir ${p}`, 0, b)
|
|
184
|
-
return sendJson(res, 200, { ok: true })
|
|
185
|
-
}
|
|
186
|
-
if (op === 'rename') {
|
|
187
|
-
const p = normalizeRemotePath(String(body.path || ''))
|
|
188
|
-
const d = normalizeRemotePath(String(body.dest || ''))
|
|
189
|
-
if (!p || !d) return sendJson(res, 400, { ok: false, error: 'path and dest required' })
|
|
190
|
-
await sftp.rename(p, d)
|
|
191
|
-
audit('move', `move ${p} → ${d}`, 0, b)
|
|
192
|
-
return sendJson(res, 200, { ok: true })
|
|
193
|
-
}
|
|
194
|
-
if (op === 'remove') {
|
|
195
|
-
const p = normalizeRemotePath(String(body.path || ''))
|
|
196
|
-
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
|
|
197
|
-
const st = await sftp.stat(p).catch(() => null)
|
|
198
|
-
if (!st) return sendJson(res, 404, { ok: false, error: 'not found' })
|
|
199
|
-
if (st.isDirectory && st.isDirectory()) {
|
|
200
|
-
await removeRemoteTree(sftp, p)
|
|
201
|
-
} else {
|
|
202
|
-
await sftp.unlink(p)
|
|
203
|
-
}
|
|
204
|
-
audit('remove', `remove ${p}`, 0, b)
|
|
205
|
-
return sendJson(res, 200, { ok: true })
|
|
206
|
-
}
|
|
207
|
-
if (op === 'write' || op === 'append') {
|
|
208
|
-
const p = normalizeRemotePath(String(body.path || ''))
|
|
209
|
-
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
|
|
210
|
-
let content = ''
|
|
211
|
-
if (op === 'append') {
|
|
212
|
-
try { content = decodeBuf(await sftp.readFile(p), body.encoding || config.encoding) } catch { /* new */ }
|
|
213
|
-
}
|
|
214
|
-
const buf = encodeText(content + String(body.content ?? ''), body.encoding || config.encoding)
|
|
215
|
-
if (op === 'write' && !(await sftp.stat(p).catch(() => null))) await mkdirRemoteDirs(sftp, remoteDirname(p))
|
|
216
|
-
await sftp.writeFile(p, buf)
|
|
217
|
-
audit(op, `${op} ${p}`, 0, b)
|
|
218
|
-
return sendJson(res, 200, { ok: true, bytes: buf.byteLength })
|
|
219
|
-
}
|
|
220
|
-
if (op === 'download') {
|
|
221
|
-
const p = normalizeRemotePath(String(body.path || ''))
|
|
222
|
-
const ws = b.ws
|
|
223
|
-
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
|
|
224
|
-
if (!ws) return sendJson(res, 400, { ok: false, error: 'no remote workspace set' })
|
|
225
|
-
const st = await sftp.stat(p).catch(() => null)
|
|
226
|
-
if (!st) return sendJson(res, 404, { ok: false, error: 'not found' })
|
|
227
|
-
if (config.maxFileBytes > 0 && st.size > config.maxFileBytes) {
|
|
228
|
-
return sendJson(res, 400, { ok: false, error: `file is ${st.size} bytes (over ${config.maxFileBytes} cap)` })
|
|
229
|
-
}
|
|
230
|
-
const base = b.mirrorDir || mirrorDirFor(ws, b.host, b.username, b.port)
|
|
231
|
-
const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
|
|
232
|
-
const local = path.join(base, rel)
|
|
233
|
-
mkdirSync(path.dirname(local), { recursive: true })
|
|
234
|
-
await sftp.fastGet(p, local)
|
|
235
|
-
audit('download', `download ${p}`, 0, b)
|
|
236
|
-
return sendJson(res, 200, { ok: true, bytes: st.size, local })
|
|
237
|
-
}
|
|
238
|
-
return sendJson(res, 400, { ok: false, error: 'unknown op' })
|
|
239
|
-
} catch (err) {
|
|
240
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
241
|
-
}
|
|
242
|
-
},
|
|
243
|
-
},
|
|
244
|
-
]
|
|
245
|
-
}
|
|
1
|
+
// Sidebar file routes: /ls /read /write /fs.
|
|
2
|
+
//
|
|
3
|
+
// Machine-scoped callers (workspace picker, settings) omit sessionId and use
|
|
4
|
+
// the active-machine pool. Session-scoped callers (explorer / file tab) MUST
|
|
5
|
+
// pass sessionId (or local=mirror cwd); those requests resolve through the
|
|
6
|
+
// same mirror binding as rw_* tools and never fall back to the active machine.
|
|
7
|
+
import { mkdirSync } from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import {
|
|
10
|
+
normalizeRemotePath, joinRemotePath, remoteDirname, mkdirRemoteDirs, toDisplayPath,
|
|
11
|
+
} from './paths.js'
|
|
12
|
+
import { requestSessionHint } from './binding.js'
|
|
13
|
+
import { listDirStructured, listRemoteDrives, removeRemoteTree, previewRemoteFile } from './remote-fs.js'
|
|
14
|
+
|
|
15
|
+
export function createFsRoutes({
|
|
16
|
+
sendJson,
|
|
17
|
+
readBody,
|
|
18
|
+
resolveRequestBinding,
|
|
19
|
+
decodeBuf,
|
|
20
|
+
encodeText,
|
|
21
|
+
audit,
|
|
22
|
+
config,
|
|
23
|
+
mirrorDirFor,
|
|
24
|
+
}) {
|
|
25
|
+
const bindOrReply = async (req, res, body = {}) => {
|
|
26
|
+
try {
|
|
27
|
+
return await resolveRequestBinding(req, body)
|
|
28
|
+
} catch (err) {
|
|
29
|
+
sendJson(res, (err && err.httpStatus) || 500, { ok: false, error: String((err && err.message) || err) })
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return [
|
|
35
|
+
{
|
|
36
|
+
kind: 'exact',
|
|
37
|
+
path: '/dsh-remote/ls',
|
|
38
|
+
handler: async (req, res) => {
|
|
39
|
+
try {
|
|
40
|
+
// A POST body counts. This route used to read the session hint from the
|
|
41
|
+
// QUERY STRING only, so `POST /ls {sessionId}` silently listed the ACTIVE
|
|
42
|
+
// machine instead of the caller's — the wrong-host hazard issue #25 is
|
|
43
|
+
// about, reachable through the API even though the browser always uses
|
|
44
|
+
// GET. `/read`, `/write` and `/fs` already honoured the body; now this one
|
|
45
|
+
// does too, and `path` may come from either place.
|
|
46
|
+
let body = {}
|
|
47
|
+
if (req.method === 'POST') {
|
|
48
|
+
try { body = JSON.parse((await readBody(req)) || '{}') } catch { body = {} }
|
|
49
|
+
}
|
|
50
|
+
const hint = requestSessionHint(req, body)
|
|
51
|
+
const b = await bindOrReply(req, res, hint)
|
|
52
|
+
if (!b) return
|
|
53
|
+
// Identity echo (external-switch defense): name the machine this
|
|
54
|
+
// listing is answered for, straight from the pool's target — the
|
|
55
|
+
// picker validates every listing against the machine it INTENDED and
|
|
56
|
+
// re-switches on mismatch (the settings page can change the active
|
|
57
|
+
// machine under a memoized picker).
|
|
58
|
+
const tgt = (b.pool && b.pool.config) || {}
|
|
59
|
+
const machine = {
|
|
60
|
+
host: String(tgt.host || b.host || ''),
|
|
61
|
+
port: Number(tgt.port) || Number(b.port) || 22,
|
|
62
|
+
username: String(tgt.username || b.username || ''),
|
|
63
|
+
}
|
|
64
|
+
await b.pool.detect()
|
|
65
|
+
const q = new URL(req.url, 'http://localhost').searchParams
|
|
66
|
+
const raw = q.get('path')
|
|
67
|
+
? decodeURIComponent(q.get('path'))
|
|
68
|
+
: (body.path ? String(body.path) : (b.ws || ''))
|
|
69
|
+
const canon = normalizeRemotePath(raw)
|
|
70
|
+
const win = b.pool.platform === 'windows'
|
|
71
|
+
if (canon === '/' || raw === '' || raw === '/') {
|
|
72
|
+
if (win || b.pool.platform !== 'posix') {
|
|
73
|
+
const drives = await listRemoteDrives(b.pool)
|
|
74
|
+
if (drives.length) return sendJson(res, 200, { path: '', platform: 'windows', items: drives, bound: b.bound, machine })
|
|
75
|
+
}
|
|
76
|
+
if (win) return sendJson(res, 200, { path: '', platform: 'windows', items: [], bound: b.bound, machine })
|
|
77
|
+
}
|
|
78
|
+
const sftp = await b.pool.sftp()
|
|
79
|
+
const out = await listDirStructured(sftp, canon)
|
|
80
|
+
const items = out.items.map((it) => ({
|
|
81
|
+
...it,
|
|
82
|
+
path: toDisplayPath(joinRemotePath(canon, it.name), b.pool.platform),
|
|
83
|
+
}))
|
|
84
|
+
return sendJson(res, 200, {
|
|
85
|
+
path: toDisplayPath(out.path, b.pool.platform),
|
|
86
|
+
platform: win ? 'windows' : 'posix',
|
|
87
|
+
items,
|
|
88
|
+
missing: !!out.missing,
|
|
89
|
+
bound: b.bound,
|
|
90
|
+
machine,
|
|
91
|
+
})
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return sendJson(res, 500, { error: String((err && err.message) || err) })
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
kind: 'exact',
|
|
99
|
+
path: '/dsh-remote/read',
|
|
100
|
+
handler: async (req, res) => {
|
|
101
|
+
if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
102
|
+
try {
|
|
103
|
+
const q = new URL(req.url, 'http://localhost').searchParams
|
|
104
|
+
let p = q.get('path') ? decodeURIComponent(q.get('path')) : ''
|
|
105
|
+
let encoding = ''
|
|
106
|
+
let maxBytes = 256 * 1024
|
|
107
|
+
let body = {}
|
|
108
|
+
if (req.method === 'POST') {
|
|
109
|
+
body = JSON.parse((await readBody(req)) || '{}')
|
|
110
|
+
if (!p) p = String(body.path || '')
|
|
111
|
+
if (body.encoding) encoding = String(body.encoding)
|
|
112
|
+
if (body.maxBytes) maxBytes = Number(body.maxBytes)
|
|
113
|
+
}
|
|
114
|
+
if (q.get('maxBytes')) maxBytes = Number(q.get('maxBytes'))
|
|
115
|
+
if (!p) return sendJson(res, 400, { ok: false, error: 'path is required' })
|
|
116
|
+
const b = await bindOrReply(req, res, body)
|
|
117
|
+
if (!b) return
|
|
118
|
+
const target = normalizeRemotePath(p)
|
|
119
|
+
let sftp
|
|
120
|
+
try {
|
|
121
|
+
sftp = await b.pool.sftp()
|
|
122
|
+
} catch (err) {
|
|
123
|
+
return sendJson(res, 500, { ok: false, error: 'sftp unavailable: ' + ((err && err.message) || err) })
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const preview = await previewRemoteFile(sftp, target, {
|
|
127
|
+
maxBytes,
|
|
128
|
+
encoding: encoding || config.encoding,
|
|
129
|
+
decodeBuf,
|
|
130
|
+
})
|
|
131
|
+
return sendJson(res, 200, preview)
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
kind: 'exact',
|
|
142
|
+
path: '/dsh-remote/write',
|
|
143
|
+
handler: async (req, res) => {
|
|
144
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
145
|
+
try {
|
|
146
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
147
|
+
const p = normalizeRemotePath(String(body.path || ''))
|
|
148
|
+
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path is required' })
|
|
149
|
+
const b = await bindOrReply(req, res, body)
|
|
150
|
+
if (!b) return
|
|
151
|
+
const sftp = await b.pool.sftp()
|
|
152
|
+
let st = null
|
|
153
|
+
try { st = await sftp.stat(p) } catch { /* new file */ }
|
|
154
|
+
if (body.expectedMtime != null && st && st.mtime !== Number(body.expectedMtime)) {
|
|
155
|
+
return sendJson(res, 409, { ok: false, error: `远端文件已变化(mtime ${st.mtime} ≠ ${body.expectedMtime}),已放弃保存——请重新读取后再编辑` })
|
|
156
|
+
}
|
|
157
|
+
const buf = encodeText(String(body.content ?? ''), body.encoding || config.encoding)
|
|
158
|
+
if (!st) await mkdirRemoteDirs(sftp, remoteDirname(p))
|
|
159
|
+
await sftp.writeFile(p, buf)
|
|
160
|
+
audit('write', `write ${p}`, 0, b)
|
|
161
|
+
const st2 = await sftp.stat(p).catch(() => null)
|
|
162
|
+
return sendJson(res, 200, { ok: true, bytes: buf.byteLength, mtime: st2 ? st2.mtime : Math.floor(Date.now() / 1000) })
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
kind: 'exact',
|
|
170
|
+
path: '/dsh-remote/fs',
|
|
171
|
+
handler: async (req, res) => {
|
|
172
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
173
|
+
try {
|
|
174
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
175
|
+
const op = String(body.op || '')
|
|
176
|
+
const b = await bindOrReply(req, res, body)
|
|
177
|
+
if (!b) return
|
|
178
|
+
const sftp = await b.pool.sftp()
|
|
179
|
+
if (op === 'mkdir') {
|
|
180
|
+
const p = normalizeRemotePath(String(body.path || ''))
|
|
181
|
+
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
|
|
182
|
+
await mkdirRemoteDirs(sftp, p)
|
|
183
|
+
audit('mkdir', `mkdir ${p}`, 0, b)
|
|
184
|
+
return sendJson(res, 200, { ok: true })
|
|
185
|
+
}
|
|
186
|
+
if (op === 'rename') {
|
|
187
|
+
const p = normalizeRemotePath(String(body.path || ''))
|
|
188
|
+
const d = normalizeRemotePath(String(body.dest || ''))
|
|
189
|
+
if (!p || !d) return sendJson(res, 400, { ok: false, error: 'path and dest required' })
|
|
190
|
+
await sftp.rename(p, d)
|
|
191
|
+
audit('move', `move ${p} → ${d}`, 0, b)
|
|
192
|
+
return sendJson(res, 200, { ok: true })
|
|
193
|
+
}
|
|
194
|
+
if (op === 'remove') {
|
|
195
|
+
const p = normalizeRemotePath(String(body.path || ''))
|
|
196
|
+
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
|
|
197
|
+
const st = await sftp.stat(p).catch(() => null)
|
|
198
|
+
if (!st) return sendJson(res, 404, { ok: false, error: 'not found' })
|
|
199
|
+
if (st.isDirectory && st.isDirectory()) {
|
|
200
|
+
await removeRemoteTree(sftp, p)
|
|
201
|
+
} else {
|
|
202
|
+
await sftp.unlink(p)
|
|
203
|
+
}
|
|
204
|
+
audit('remove', `remove ${p}`, 0, b)
|
|
205
|
+
return sendJson(res, 200, { ok: true })
|
|
206
|
+
}
|
|
207
|
+
if (op === 'write' || op === 'append') {
|
|
208
|
+
const p = normalizeRemotePath(String(body.path || ''))
|
|
209
|
+
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
|
|
210
|
+
let content = ''
|
|
211
|
+
if (op === 'append') {
|
|
212
|
+
try { content = decodeBuf(await sftp.readFile(p), body.encoding || config.encoding) } catch { /* new */ }
|
|
213
|
+
}
|
|
214
|
+
const buf = encodeText(content + String(body.content ?? ''), body.encoding || config.encoding)
|
|
215
|
+
if (op === 'write' && !(await sftp.stat(p).catch(() => null))) await mkdirRemoteDirs(sftp, remoteDirname(p))
|
|
216
|
+
await sftp.writeFile(p, buf)
|
|
217
|
+
audit(op, `${op} ${p}`, 0, b)
|
|
218
|
+
return sendJson(res, 200, { ok: true, bytes: buf.byteLength })
|
|
219
|
+
}
|
|
220
|
+
if (op === 'download') {
|
|
221
|
+
const p = normalizeRemotePath(String(body.path || ''))
|
|
222
|
+
const ws = b.ws
|
|
223
|
+
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
|
|
224
|
+
if (!ws) return sendJson(res, 400, { ok: false, error: 'no remote workspace set' })
|
|
225
|
+
const st = await sftp.stat(p).catch(() => null)
|
|
226
|
+
if (!st) return sendJson(res, 404, { ok: false, error: 'not found' })
|
|
227
|
+
if (config.maxFileBytes > 0 && st.size > config.maxFileBytes) {
|
|
228
|
+
return sendJson(res, 400, { ok: false, error: `file is ${st.size} bytes (over ${config.maxFileBytes} cap)` })
|
|
229
|
+
}
|
|
230
|
+
const base = b.mirrorDir || mirrorDirFor(ws, b.host, b.username, b.port)
|
|
231
|
+
const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
|
|
232
|
+
const local = path.join(base, rel)
|
|
233
|
+
mkdirSync(path.dirname(local), { recursive: true })
|
|
234
|
+
await sftp.fastGet(p, local)
|
|
235
|
+
audit('download', `download ${p}`, 0, b)
|
|
236
|
+
return sendJson(res, 200, { ok: true, bytes: st.size, local })
|
|
237
|
+
}
|
|
238
|
+
return sendJson(res, 400, { ok: false, error: 'unknown op' })
|
|
239
|
+
} catch (err) {
|
|
240
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
]
|
|
245
|
+
}
|